From 064c0c9074fbd909cef308a0f9dd375ef5e779e2 Mon Sep 17 00:00:00 2001 From: quipsol <245758987+quipsol@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:37:44 +0200 Subject: [PATCH 1/6] Custom Epoch Support custom epochs are fully supported now --- Abstracts/CustomCharacterModel.cs | 135 ++++++- Abstracts/CustomEpochEra.cs | 58 +++ Abstracts/CustomEpochModel.cs | 354 ++++++++++++++++++ Abstracts/CustomStoryModel.cs | 44 +++ BaseLib.csproj | 8 +- Extensions/TypePrefix.cs | 14 + .../Compatibility/UnknownCharacterPatches.cs | 30 +- Patches/Content/ContentPatches.cs | 27 ++ Patches/Content/CustomEpochPatches.cs | 6 + Patches/PostModInitPatch.cs | 33 +- Utils/ReflectionUtils.cs | 48 +++ 11 files changed, 713 insertions(+), 44 deletions(-) create mode 100644 Abstracts/CustomEpochEra.cs create mode 100644 Abstracts/CustomEpochModel.cs create mode 100644 Abstracts/CustomStoryModel.cs create mode 100644 Patches/Content/CustomEpochPatches.cs diff --git a/Abstracts/CustomCharacterModel.cs b/Abstracts/CustomCharacterModel.cs index 123ebea2..8e9a5a56 100644 --- a/Abstracts/CustomCharacterModel.cs +++ b/Abstracts/CustomCharacterModel.cs @@ -13,10 +13,15 @@ using BaseLib.Patches.UI; using BaseLib.Utils.NodeFactories; using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Models.Characters; using MegaCrit.Sts2.Core.Multiplayer.Game.Lobby; using MegaCrit.Sts2.Core.Nodes.RestSite; using MegaCrit.Sts2.Core.Nodes.Screens.CharacterSelect; +using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; using MegaCrit.Sts2.Core.Nodes.Screens.Shops; +using MegaCrit.Sts2.Core.Saves; +using MegaCrit.Sts2.Core.Timeline; +using MegaCrit.Sts2.Core.Timeline.Epochs; namespace BaseLib.Abstracts; @@ -106,8 +111,42 @@ public CustomCharacterModel() public override float CastAnimDelay => 0.25f; protected override CharacterModel? UnlocksAfterRunAs => null; - - + + // These epochs are ordered here based on the numbers the base game uses 1->7 + // Renamed to better show what unlocks them. + + /// + /// This Epoch usually unlocks the character.

+ /// When overriden, automatically unlocks the Epoch based on . + /// If that is null, it unlocks after the vey first Ironclad run similar to The Silent + ///
+ public virtual EpochModel? UnlockEpoch => null; + /// + /// This Epoch usually unlocks cards. + /// + public virtual EpochModel? Act1Epoch => null; + /// + /// This Epoch usually unlocks relics. + /// + public virtual EpochModel? Act2Epoch => null; + /// + /// This Epoch usually unlocks potions. + /// + public virtual EpochModel? Act3Epoch => null; + /// + /// This Epoch usually unlocks cards. + /// + public virtual EpochModel? FifteenElitesEpoch => null; + /// + /// This Epoch usually unlocks relics. + /// + public virtual EpochModel? FifteenBossesEpoch => null; + /// + /// This Epoch usually unlocks cards. + /// + public virtual EpochModel? AscensionOneEpoch => null; + + /// /// Override to provide a custom NCreatureVisuals scene. /// If not overridden, an NCreatureVisuals will be generated from CustomVisualPath. @@ -208,6 +247,98 @@ public void RegisterSceneConversions() CustomEnergyCounterPath?.RegisterSceneForConversion(); } + + + + // The following two patch classes need access to "UnlocksAfterRunAs" + [HarmonyPatch] + private static class CharacterUnlockEpochLogic + { + // We check it every NMainMenu load because of profile switching, deletion, importing + // TODO: There might be a better place to hook this up to. -> Run once on game start and then only when profile changes + [HarmonyPatch(typeof(NMainMenu), nameof(NMainMenu._Ready))] + [HarmonyPrefix] + private static void CheckUnlockConditions() + { + if (!SaveManager.Instance.IsEpochRevealed()) return; + foreach (var epochModel in CustomContentDictionary.CustomCharacters + .Where(c => c.UnlockEpoch is not null && c.UnlocksAfterRunAs is null) + .Select(c => c.UnlockEpoch)) + { + if (SaveManager.Instance.IsEpochRevealed(epochModel!.Id) + || SaveManager.Instance.Progress.IsEpochObtained(epochModel.Id)) continue; + SaveManager.Instance.ObtainEpochOverride(epochModel.Id, EpochState.Obtained); + } + + } + } + + [HarmonyPatch] + private static class EpochInsertions + { + [HarmonyPatch(typeof(NeowEpoch), nameof(NeowEpoch.QueueUnlocks))] + [HarmonyPrefix] + private static void QueueUnlocksInsert() + { + // Ironclad doesn't have an Ironclad1Epoch so we put them here too + foreach (var epochModel in CustomContentDictionary.CustomCharacters + .Where(c => c.UnlockEpoch is not null && c.UnlocksAfterRunAs is null or Ironclad) + .Select(c => c.UnlockEpoch)) + { + SaveManager.Instance.ObtainEpochOverride(epochModel!.Id, EpochState.ObtainedNoSlot); + } + } + + + // Does not support unlocking after custom characters + [HarmonyPatch] + internal static class TimelineExpansionPatch + { + private static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(NeowEpoch), nameof(NeowEpoch.GetTimelineExpansion)); + yield return AccessTools.Method(typeof(Silent1Epoch), nameof(Silent1Epoch.GetTimelineExpansion)); + yield return AccessTools.Method(typeof(Regent1Epoch), nameof(Regent1Epoch.GetTimelineExpansion)); + yield return AccessTools.Method(typeof(Necrobinder1Epoch), nameof(Necrobinder1Epoch.GetTimelineExpansion)); + yield return AccessTools.Method(typeof(Defect1Epoch), nameof(Defect1Epoch.GetTimelineExpansion)); + } + + [HarmonyPostfix] + private static void Postfix(MethodBase __originalMethod, ref EpochModel[] __result) + { + var unlockSource = __originalMethod.DeclaringType switch + { + var t when t == typeof(NeowEpoch) => typeof(Ironclad), // Ironclad special case + var t when t == typeof(Silent1Epoch) => typeof(Silent), + var t when t == typeof(Regent1Epoch) => typeof(Regent), + var t when t == typeof(Necrobinder1Epoch) => typeof(Necrobinder), + var t when t == typeof(Defect1Epoch) => typeof(Defect), + _ => null + }; + + if (unlockSource is null) + { + BaseLibMain.Logger.Warn("BaseLib currently only supports base characters"); + return; + } + + __result = + [ + .. __result, + .. CustomContentDictionary.CustomCharacters + .Where(c => + c.UnlockEpoch is not null && + (unlockSource == typeof(Ironclad) + ? c.UnlocksAfterRunAs is null or Ironclad + : c.UnlocksAfterRunAs is not null + && c.UnlocksAfterRunAs == (CharacterModel)ModelDb.Get(unlockSource))) + .Select(c => c.UnlockEpoch!) + ]; + } + } + } + + } public readonly struct CustomEnergyCounter(Func pathFunc, Color outlineColor, Color burstColor) { diff --git a/Abstracts/CustomEpochEra.cs b/Abstracts/CustomEpochEra.cs new file mode 100644 index 00000000..3b05bb7b --- /dev/null +++ b/Abstracts/CustomEpochEra.cs @@ -0,0 +1,58 @@ +using Godot; +using MegaCrit.Sts2.Core.Assets; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Timeline; + +namespace BaseLib.Abstracts; + +public abstract class CustomEpochEra +{ + public abstract EpochEra CustomEra { get; } + + /// + /// The base game Era used as a frame of reference for where to insert the custom era. + /// + public abstract EpochEra ReferenceEra { get; } + /// + /// The direction in which the custom era will be inserted. + /// + public abstract RelativeEraDirection Direction { get; } + /// + /// Used for ordering multiple custom eras at the same Reference Era and . + /// Larger numbers are further away from the reference era. + /// + public virtual int DirectionDepth => 0; + + /// + /// The icon displayed on the timeline line at the bottom of the era. + /// + public abstract string EraIconPath { get; } + public Texture2D EraIconTexture => PreloadManager.Cache.GetTexture2D(EraIconPath); + /// + /// Stops the game from auto-sizing the era icon. + /// + public virtual bool UseOriginalImageSize => false; + /// + /// Stops the game from applying the default tint to the era icon. + /// + public virtual bool DisableTinting => false; +} + +/// +/// The direction in which a Custom Era will be inserted, in reference to its Reference Era +/// +public enum RelativeEraDirection +{ + /// + /// Shouldn't be used. + /// + None, + /// + /// Insert to the left. + /// + Before, + /// + /// Insert to the right. + /// + After +} \ No newline at end of file diff --git a/Abstracts/CustomEpochModel.cs b/Abstracts/CustomEpochModel.cs new file mode 100644 index 00000000..19a7c72d --- /dev/null +++ b/Abstracts/CustomEpochModel.cs @@ -0,0 +1,354 @@ +using System.Reflection; +using System.Reflection.Emit; +using BaseLib.Extensions; +using BaseLib.Patches; +using BaseLib.Patches.Content; +using BaseLib.Utils; +using HarmonyLib; +using MegaCrit.Sts2.Core.Entities.Players; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.Models.Characters; +using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; +using MegaCrit.Sts2.Core.Nodes.Screens.Timeline; +using MegaCrit.Sts2.Core.Saves; +using MegaCrit.Sts2.Core.Saves.Managers; +using MegaCrit.Sts2.Core.Saves.Runs; +using MegaCrit.Sts2.Core.Timeline; +using MegaCrit.Sts2.Core.Timeline.Epochs; +using MegaCrit.Sts2.Core.Unlocks; + +namespace BaseLib.Abstracts; + +/// +/// These are not stored in +/// +public abstract class CustomEpochModel : EpochModel, ICustomModel +{ + + // TODO: Prefixing Mod Id. + // No ModelDb entry so the ModelDb.GetEntry prefix patch won't work + /// + /// If you override this, add your mod prefix to it! + /// + public override string Id => $"{GetType().GetPrefix()}{StringHelper.Slugify(GetType().Name).ToUpperInvariant()}"; + + /// + /// The large image displayed when clicking the Epoch. + /// + public abstract string CustomRealPortraitPath { get; } // png + + /// + /// The small image displayed on the Timeline. + /// + public abstract string CustomPackedPortraitPath { get; } // tres + + // TODO: See if this can be foregone by checking the "Era" property for a custom era. + /// + /// Override only if this Epoch is part of a Custom Era. + /// + public virtual CustomEpochEra? CustomEra => null; + + /// + /// Set to true if your epoch unlocks cards so it can be added to + /// + public virtual bool UnlocksCards => false; + + [HarmonyPatch] + private static class PropertyRedirections + { + [HarmonyPatch(typeof(EpochModel), "RealPortraitPath", MethodType.Getter)] + [HarmonyPrefix] + private static bool CustomEpochRealPortraitPath(EpochModel __instance, ref string? __result) + { + if (__instance is not CustomEpochModel customEpoch) return true; + __result = customEpoch.CustomRealPortraitPath; + return false; + } + + [HarmonyPatch(typeof(EpochModel), nameof(PackedPortraitPath), MethodType.Getter)] + [HarmonyPrefix] + private static bool CustomEpochPackedPortraitPath(EpochModel __instance, ref string? __result) + { + if (__instance is not CustomEpochModel customEpoch) return true; + __result = customEpoch.CustomPackedPortraitPath; + return false; + } + } + + /// + /// Should only be called once from + /// + public static void FillEpochDictionaries(List models) + { + BaseLibMain.Logger.Info("Inserting CustomEpochs into dictionaries"); + var epochModelType = typeof(EpochModel); + var epochTypeDictionary = (AccessTools.Field(epochModelType, "_epochTypeDictionary").GetValue(null) as Dictionary)!; + var typeToIdDictionary = (AccessTools.Field(epochModelType, "_typeToIdDictionary").GetValue(null) as Dictionary)!; + foreach (var customEpochModel in models) + { + var type = customEpochModel.GetType(); + BaseLibMain.Logger.Debug($"CustomEpoch Type: {type.Name} | Id: {customEpochModel.Id} "); + CustomContentDictionary.AddEpoch(customEpochModel); + epochTypeDictionary[customEpochModel.Id] = type; + typeToIdDictionary[type] = customEpochModel.Id; + } + } + + + [HarmonyPatch] + public static class CustomEpochPatches + { + [HarmonyPatch] + public static class DuplicateEraPositionHandler + { + private static readonly SpireField OverwrittenEraPositions = new(() => true); + + [HarmonyTranspiler] + [HarmonyPatch(typeof(NEraColumn), nameof(NEraColumn.AddSlot))] + private static List AdjustNEpochSlotEraPosition(IEnumerable instructions) + { + var enforceUniqueEraPosition = typeof(DuplicateEraPositionHandler).Method(nameof(EnforceUniqueEraPosition)); + var matcher = new CodeMatcher(instructions) + .MatchStartForward([ + new CodeMatch(OpCodes.Stloc_0), + ]) + .ThrowIfInvalid("Could not find correct position") + .InsertAfter([ + new CodeInstruction(OpCodes.Ldarg_0), + new CodeInstruction(OpCodes.Ldloc_0), + new CodeInstruction(OpCodes.Call, enforceUniqueEraPosition), + ]); + var list = matcher.InstructionEnumeration().ToList(); + for (var i = 0; i < list.Count; i++) + { + BaseLibMain.Logger.Info($"Instruction {i} => OpCode: {list[i].opcode} | Operand: {list[i].operand}"); + } + return matcher.InstructionEnumeration().ToList(); + } + + private static void EnforceUniqueEraPosition(NEraColumn nEraColumn, NEpochSlot nEpochSlot) + { + var allCurrentNEpochSlots = nEraColumn.GetChildren().OfType().ToList(); + var oldEraPosition = nEpochSlot.eraPosition; + while (allCurrentNEpochSlots.Any(o => o.eraPosition == nEpochSlot.eraPosition)) + { + nEpochSlot.eraPosition++; + } + if (oldEraPosition == nEpochSlot.eraPosition) return; + OverwrittenEraPositions.Set(nEpochSlot, true); + BaseLibMain.Logger.Info($"Moved EpochSlot position for Epoch {nEpochSlot.model.Id} from {oldEraPosition} to {nEpochSlot.eraPosition}"); + } + + [HarmonyPatch(typeof(NTimelineScreen), nameof(NTimelineScreen.InitScreen), MethodType.Async)] + [HarmonyTranspiler] + private static List ResolveSetStateForNewEraPositions(IEnumerable instructions) + { + var insts = instructions.ToList(); + for (var i = 0; i < insts.Count; i++) + { + BaseLibMain.Logger.Info($"Instruction {i} => OpCode: {insts[i].opcode} | Operand: {insts[i].operand}"); + } + + //ldloc 12 9 + var getActualEraPosition = typeof(DuplicateEraPositionHandler).Method(nameof(GetActualEraPosition)); + var matcher = new CodeMatcher(instructions) + .MatchStartForward([ + new CodeMatch(OpCodes.Isinst), + ]) + .ThrowIfInvalid("Could not find correct position") + .Advance(2); + + var nEpochSlot = matcher.Instruction.operand; + matcher.Advance(1); + var target = (Label)matcher.Instruction.operand; + matcher.Advance(1) + .RemoveInstructions(2); + var epochModel = matcher.Instruction.operand; + matcher.RemoveInstructions(3) + .Insert([ + new CodeInstruction(OpCodes.Ldloc, nEpochSlot), + new CodeInstruction(OpCodes.Ldloc, epochModel), + new CodeInstruction(OpCodes.Call, getActualEraPosition), + new CodeInstruction(OpCodes.Brfalse_S, target), + ]) + ; + + var list = matcher.InstructionEnumeration().ToList(); + for (var i = 0; i < list.Count; i++) + { + BaseLibMain.Logger.Info($"Instruction {i} => OpCode: {list[i].opcode} | Operand: {list[i].operand}"); + } + return matcher.InstructionEnumeration().ToList(); + } + private static bool GetActualEraPosition(NEpochSlot nEpochSlot, EpochModel epochModel) + { + return nEpochSlot.model.Id == epochModel.Id; + } + } + + + // If the patch above stops working we can also do this. + // However, the patch above runs BEFORE NEpochSlot._Ready() is called, where as this runs after + + // [HarmonyPatch(typeof(NEraColumn), nameof(NEraColumn.AddSlot))] + // [HarmonyPostfix] + // private static void AdjustNEpochSlotEraPosition(NEraColumn __instance) + // { + // var nEpochSlot = __instance.GetChildren().OfType().FirstOrDefault(); + // if (nEpochSlot is null) return; + // var allCurrentNEpochSlots = __instance.GetChildren().OfType().Except([nEpochSlot]).ToList(); + // var oldEraPosition = nEpochSlot.eraPosition; + // while (allCurrentNEpochSlots.Any(o => o.eraPosition == nEpochSlot.eraPosition)) + // nEpochSlot.eraPosition++; + // if (oldEraPosition == nEpochSlot.eraPosition) return; + // nEpochSlot.Name = $"Slot{nEpochSlot.eraPosition}"; // We dont need to move the child because the game always moves them to pos 0 + // BaseLibMain.Logger.Info($"Moved EpochSlot position for Epoch {nEpochSlot.model.Id} from {oldEraPosition} to {nEpochSlot.eraPosition}"); + // } + + [HarmonyPatch(typeof(SaveManager), "GetCardUnlockEpochIds")] + [HarmonyPostfix] + private static void InsertCardUnlockCustomEpochs(ref string[] __result) + { + __result = [.. __result, .. CustomContentDictionary.CustomEpochs.Where(e => e.UnlocksCards).Select(e => e.Id)]; + } + + [HarmonyPatch(typeof(UnlockState), nameof(UnlockState.Characters), MethodType.Getter)] + [HarmonyPostfix] + private static void LockCharactersWithUnlockEpoch(UnlockState __instance, ref IEnumerable __result) + { + var unlockedEpochIds = (AccessTools.Field(typeof(UnlockState), "_unlockedEpochIds").GetValue(__instance) as HashSet)!; + var characterModels = __result as CharacterModel[] ?? __result.ToArray(); + __result = characterModels.Except(characterModels.OfType() + .Where(c => c.UnlockEpoch is not null && !unlockedEpochIds.Contains(c.UnlockEpoch.Id))); + } + + + private static readonly MethodInfo? TryObtainEpochMidRunMethod = typeof(ProgressSaveManager) + .GetMethod("TryObtainEpochMidRun", BindingFlags.Instance | BindingFlags.NonPublic); + private static readonly MethodInfo? TryObtainEpochPostRunMethod = typeof(ProgressSaveManager) + .GetMethod("TryObtainEpochPostRun", BindingFlags.Instance | BindingFlags.NonPublic); + private static readonly MethodInfo? GetEliteEncountersMethod = typeof(ProgressSaveManager) + .GetMethod("GetEliteEncounters", BindingFlags.Static | BindingFlags.NonPublic); + + // These skips would always skip, even if the creator intended for their own way to implement them! + // For now, I will leave it as is because adding booleans to the CustomCharacterModel in case someone might want to use these Epochs + // but does not want to set them in the characters class, is unnecessary. (it isn't too difficult to counter patch either. The main issue + // is figuring out this was the reason for them not running which is helped by this comment here. Hello there!) + [HarmonyPatch(typeof(ProgressSaveManager), "ObtainCharUnlockEpoch")] + [HarmonyPrefix] + private static bool SkipCharUnlockEpochIfUnsupported(ProgressSaveManager __instance, Player localPlayer, int act) + { + if (localPlayer.Character is not CustomCharacterModel ccm) + return true; + switch (act) + { + case 0: + if(ccm.Act1Epoch is not null) + TryObtainEpochMidRunMethod?.Invoke(__instance, [ccm.Act1Epoch, localPlayer]); + break; + case 1: + if(ccm.Act2Epoch is not null) + TryObtainEpochMidRunMethod?.Invoke(__instance, [ccm.Act2Epoch, localPlayer]); + break; + case 2: + if(ccm.Act3Epoch is not null) + TryObtainEpochMidRunMethod?.Invoke(__instance, [ccm.Act3Epoch, localPlayer]); + break; + case 3: + BaseLibMain.Logger.Warn("BaseLib does not support Act 4 yet."); + break; + default: + BaseLibMain.Logger.Warn($"Unsupported Act: {act}"); + break; + } + return false; + } + + + // This and the two following patches currently copy the entire methods logic into the prefix + // In the case of 15 bosses and elites it might be better to just transpile at the "throw" to + // check for a custom character with an epoch. + // Since these two methods store the epoch object in a variable unlike the Act1-3 + // check which runs a switch on the act number and then builds the Id using strings. + + [HarmonyPatch(typeof(ProgressSaveManager), "CheckFifteenBossesDefeatedEpoch")] + [HarmonyPrefix] + private static bool SkipBossEpochIfUnsupported(ProgressSaveManager __instance, Player localPlayer) + { + if(localPlayer.Character is not CustomCharacterModel customCharacterModel) + return true; + if(!localPlayer.Character.IsPlayable || customCharacterModel.FifteenBossesEpoch is null) + return false; + + var bossEncounters = ModelDb.Acts.SelectMany(a => a.AllBossEncounters.Select(e => e.Id)).ToHashSet(); + + var num = 0; + foreach (var encounterStats in __instance.Progress.EncounterStats.Values) + { + if (!bossEncounters.Contains(encounterStats.Id)) continue; + foreach (var fightStat in encounterStats.FightStats.Where(fightStat => fightStat.Character == customCharacterModel.Id)) + { + num += fightStat.Wins; + break; + } + } + if (num < 15) + return false; + var res = TryObtainEpochMidRunMethod?.Invoke(__instance, [customCharacterModel.FifteenBossesEpoch, localPlayer]); + if(res is true) + BaseLibMain.Logger.Info($"Epoch obtained for beating 15 Bosses"); + return false; + } + + [HarmonyPatch(typeof(ProgressSaveManager), "CheckFifteenElitesDefeatedEpoch")] + [HarmonyPrefix] + private static bool SkipEliteEpochIfUnsupported(ProgressSaveManager __instance, Player localPlayer) + { + if(localPlayer.Character is not CustomCharacterModel customCharacterModel) + return true; + if(!localPlayer.Character.IsPlayable || customCharacterModel.FifteenElitesEpoch is null) + return false; + + var eliteEncountersObject = GetEliteEncountersMethod?.Invoke(null, []) ?? throw new NullReferenceException(); + if (eliteEncountersObject is not HashSet eliteEncounters) return false; + + var num = 0; + foreach (var encounterStats in __instance.Progress.EncounterStats.Values) + { + if (!eliteEncounters.Contains(encounterStats.Id)) continue; + foreach (var fightStat in encounterStats.FightStats.Where(fightStat => fightStat.Character == customCharacterModel.Id)) + { + num += fightStat.Wins; + break; + } + } + if (num < 15) + return false; + var res = TryObtainEpochMidRunMethod?.Invoke(__instance, [customCharacterModel.FifteenElitesEpoch, localPlayer]); + if(res is true) + BaseLibMain.Logger.Info($"Epoch obtained for beating 15 Elites"); + return false; + } + + [HarmonyPatch(typeof(ProgressSaveManager), "CheckAscensionOneCompleted")] + [HarmonyPrefix] + private static bool SkipAscensionOneEpochIfUnsupported(ProgressSaveManager __instance, SerializablePlayer serializablePlayer, SerializableRun serializableRun) + { + var characterModel = ModelDb.GetById(serializablePlayer.CharacterId!); + if(characterModel is not CustomCharacterModel customCharacterModel) + return true; + if (serializableRun.Ascension != 1 || customCharacterModel.AscensionOneEpoch is null) + return false; + var res = TryObtainEpochPostRunMethod?.Invoke(__instance, [customCharacterModel.AscensionOneEpoch, serializablePlayer, serializableRun]); + if(res is true) + BaseLibMain.Logger.Info($"Epoch obtained for beating Ascension 1"); + return false; + } + + + + + + } +} \ No newline at end of file diff --git a/Abstracts/CustomStoryModel.cs b/Abstracts/CustomStoryModel.cs new file mode 100644 index 00000000..35d8a7a6 --- /dev/null +++ b/Abstracts/CustomStoryModel.cs @@ -0,0 +1,44 @@ +using BaseLib.Extensions; +using BaseLib.Patches; +using BaseLib.Patches.Content; +using HarmonyLib; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.Timeline; + +namespace BaseLib.Abstracts; + + + +/// +/// These are not stored in +/// +public abstract class CustomStoryModel : StoryModel, ICustomModel +{ + + // TODO: Prefixing Mod Id. + // Id gets slugified in a place where we can't easily add the prefix after like with ModelDb.GetEntry + // Id not uppercased/Slugified because game will Slugify it later! + /// + /// Must match 1 to 1 with where used.
+ /// This currently does not automatically add the Mod Prefix! + ///
+ protected override string Id => $"{GetType().GetPrefix()}{GetType().Name}"; + + /// + /// Should only be called once from + /// + public static void FillStoryDictionary(List models) + { + BaseLibMain.Logger.Info("Inserting CustomStories into dictionary"); + var storyTypeDictionary = (AccessTools.Field(typeof(StoryModel), "_storyTypeDictionary").GetValue(null) as Dictionary)!; + foreach (var customStoryModel in models) + { + var type = customStoryModel.GetType(); + BaseLibMain.Logger.Debug($"CustomStory Type: {type.Name} | Id: {customStoryModel.Id} | Saved in dict as: {StringHelper.Slugify(customStoryModel.Id)}"); + CustomContentDictionary.AddStory(customStoryModel); + // slugify it to match what the game does for lookup even if it currently removes the prefix - + storyTypeDictionary[StringHelper.Slugify(customStoryModel.Id)] = type; + } + } +} \ No newline at end of file diff --git a/BaseLib.csproj b/BaseLib.csproj index 9bbe7e05..507c1116 100644 --- a/BaseLib.csproj +++ b/BaseLib.csproj @@ -53,7 +53,6 @@ $(Sts2DataDir)/SmartFormat.dll false - @@ -82,6 +81,13 @@ + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/Extensions/TypePrefix.cs b/Extensions/TypePrefix.cs index 4cd2f58e..d66ba7ff 100644 --- a/Extensions/TypePrefix.cs +++ b/Extensions/TypePrefix.cs @@ -12,6 +12,20 @@ public static string GetPrefix(this Type t) dotIndex = t.Namespace.Length; return $"{t.Namespace[..dotIndex].ToUpperInvariant()}{PrefixSplitChar}"; } + + /// + /// Returns the Mods Prefix without modifying it + /// + /// + public static string GetRawPrefix(this Type t, bool includePrefixSplitCharacter = false) + { + if (t.Namespace == null) + return ""; + var dotIndex = t.Namespace.IndexOf('.'); + if (dotIndex == -1) + dotIndex = t.Namespace.Length; + return $"{t.Namespace[..dotIndex]}{(includePrefixSplitCharacter ? PrefixSplitChar : "")}"; + } public static string GetRootNamespace(this Type t) { diff --git a/Patches/Compatibility/UnknownCharacterPatches.cs b/Patches/Compatibility/UnknownCharacterPatches.cs index 57a613ce..fbe94395 100644 --- a/Patches/Compatibility/UnknownCharacterPatches.cs +++ b/Patches/Compatibility/UnknownCharacterPatches.cs @@ -63,34 +63,6 @@ private static void SkipUnknownCharacter(SaveManager __instance, ref bool __resu } } - //ProgressSaveManager - //TODO - allow custom epochs? For now there's a lot of things to add to support that. - [HarmonyPatch(typeof(ProgressSaveManager), "ObtainCharUnlockEpoch")] - private class SkipCharUnlockEpoch - { - [HarmonyPrefix] - private static bool SkipIfUnsupported(Player localPlayer) - { - return localPlayer.Character is not ICustomModel; - } - } - [HarmonyPatch(typeof(ProgressSaveManager), "CheckFifteenBossesDefeatedEpoch")] - private class SkipBossEpochCheck - { - [HarmonyPrefix] - private static bool SkipIfUnsupported(Player localPlayer) - { - return localPlayer.Character is not ICustomModel; - } - } - [HarmonyPatch(typeof(ProgressSaveManager), "CheckFifteenElitesDefeatedEpoch")] - private class SkipEliteEpochCheck - { - [HarmonyPrefix] - private static bool SkipIfUnsupported(Player localPlayer) - { - return localPlayer.Character is not ICustomModel; - } - } + } \ No newline at end of file diff --git a/Patches/Content/ContentPatches.cs b/Patches/Content/ContentPatches.cs index 8dc8c0a5..0918df17 100644 --- a/Patches/Content/ContentPatches.cs +++ b/Patches/Content/ContentPatches.cs @@ -15,6 +15,7 @@ using MegaCrit.Sts2.Core.Rooms; using MegaCrit.Sts2.Core.Runs; using MegaCrit.Sts2.Core.Saves; +using MegaCrit.Sts2.Core.Timeline; using MegaCrit.Sts2.Core.Timeline.Epochs; using MegaCrit.Sts2.Core.Unlocks; @@ -39,6 +40,8 @@ public static class CustomContentDictionary ///
public static readonly List SharedCustomEvents = []; public static readonly List CustomActs = []; + public static readonly List CustomEpochs = []; + public static readonly List CustomStories = []; static CustomContentDictionary() { @@ -123,6 +126,19 @@ public static void AddCharacter(CustomCharacterModel character) } } + public static void AddEpoch(CustomEpochModel epochModel) + { + if (!RegisterType(epochModel.GetType())) return; + CustomEpochs.Add(epochModel); + } + + public static void AddStory(CustomStoryModel storyModel) + { + if (!RegisterType(storyModel.GetType())) return; + + CustomStories.Add(storyModel); + } + private static bool IsValidPool(Type modelType, Type poolType) { var basePoolType = poolType.BaseType; @@ -408,3 +424,14 @@ static IEnumerable AddCustomEvents(IEnumerable result, A } } } + +[HarmonyPatch(typeof(EpochModel), "EpochIds", MethodType.Getter)] +public class EpochModelCustomEpochsPatch +{ + + [HarmonyPostfix] + private static void InsertEpochIds(EpochModel __instance, ref IEnumerable __result) + { + __result = [.. __result, .. CustomContentDictionary.CustomEpochs.Select(x => x.Id)]; + } +} \ No newline at end of file diff --git a/Patches/Content/CustomEpochPatches.cs b/Patches/Content/CustomEpochPatches.cs new file mode 100644 index 00000000..8637d2fa --- /dev/null +++ b/Patches/Content/CustomEpochPatches.cs @@ -0,0 +1,6 @@ +using System.Reflection.Emit; +using BaseLib.Abstracts; +using HarmonyLib; +using MegaCrit.Sts2.Core.Nodes.Screens.Timeline; + +namespace BaseLib.Patches.Content; diff --git a/Patches/PostModInitPatch.cs b/Patches/PostModInitPatch.cs index 1da047e0..f34226ee 100644 --- a/Patches/PostModInitPatch.cs +++ b/Patches/PostModInitPatch.cs @@ -1,5 +1,6 @@ using System.Reflection; using BaseLib.Abstracts; +using BaseLib.Extensions; using BaseLib.Patches.Content; using BaseLib.Patches.Features; using BaseLib.Patches.Saves; @@ -10,6 +11,7 @@ using MegaCrit.Sts2.Core.Localization; using MegaCrit.Sts2.Core.Modding; using MegaCrit.Sts2.Core.Saves.Runs; +using MegaCrit.Sts2.Core.Timeline; using SmartFormat; using SmartFormat.Core.Extensions; @@ -21,7 +23,7 @@ namespace BaseLib.Patches; //TODO - If no mods that modify gameplay and use baselib as a dependency are enabled, exclude basemod models from database? //This would allow features like vitality to be merged. -[HarmonyPatch(typeof(LocManager), nameof(LocManager.Initialize))] +[HarmonyPatch(typeof(LocManager), nameof(LocManager.Initialize))] class PostModInitPatch { private static bool _initialized = false; @@ -32,7 +34,7 @@ private static void PostModInit() { if (_initialized) return; _initialized = true; - + BaseLibMain.Logger.Info("Performing post-mod init patch"); foreach (var mod in ModManager.GetLoadedMods()) @@ -55,27 +57,28 @@ private static void PostModInit() //Register custom save data. CardModifier.RegisterSave(); } - + CustomMessageWrapper.Initialize(); CustomTargetedMessageWrapper.Initialize(); - + Harmony harmony = new("PostModInit"); AddActContent.Patch(harmony); - + + InsertEpochSystemRelevantInformationIntoDictionaries(); ModInterop interop = new(); - + foreach (var type in ReflectionHelper.ModTypes) { interop.ProcessType(harmony, type); - if (type.IsAssignableTo(typeof(IAutoRegisterFormatSpecifier)) && + if (type.IsAssignableTo(typeof(IAutoRegisterFormatSpecifier)) && type is { IsAbstract: false, IsInterface: false }) { try { - Smart.Default.AddExtensions((IFormatter) type.CreateInstance()); + Smart.Default.AddExtensions((IFormatter)type.CreateInstance()); BaseLibMain.Logger.Info($"Added custom format specifier {type.Name}"); } catch (Exception e) @@ -98,8 +101,8 @@ private static void PostModInit() else if (!SavePatchUtils.IsHolderTypeBaseSupported(prop.DeclaringType)) { var endMsg = ExtendedSaveTypes.IsSaveHolderSupported(type) - ? "change to a SavedSpireField for BaseLib to save it." - : "this type is currently also unsupported by BaseLib for saved values."; + ? "change to a SavedSpireField for BaseLib to save it." + : "this type is currently also unsupported by BaseLib for saved values."; BaseLibMain.Logger.Warn($"SavedProperty {prop.Name} will not work on type {type.Name}; {endMsg}"); } else @@ -125,10 +128,10 @@ private static void PostModInit() private static void CheckSpecialSpireField(FieldInfo field) { Type fType = field.FieldType; - + if (!fType.IsGenericType) return; - + var genericTypeDef = fType.GetGenericTypeDefinition(); if (genericTypeDef != typeof(SavedSpireField<,>) && @@ -137,4 +140,10 @@ private static void CheckSpecialSpireField(FieldInfo field) field.GetValue(null); //Trigger field initialization } + + private static void InsertEpochSystemRelevantInformationIntoDictionaries() + { + CustomStoryModel.FillStoryDictionary(ReflectionUtils.GetListOfInstantiatedSubclasses()); + CustomEpochModel.FillEpochDictionaries(ReflectionUtils.GetListOfInstantiatedSubclasses()); + } } \ No newline at end of file diff --git a/Utils/ReflectionUtils.cs b/Utils/ReflectionUtils.cs index 96139c96..d346f5b7 100644 --- a/Utils/ReflectionUtils.cs +++ b/Utils/ReflectionUtils.cs @@ -61,4 +61,52 @@ public static class ReflectionUtils return (obj, value) => backingField.SetValue(obj, value); } } + + /// + /// Returns a list of instantiated objects, one for each class that inherits from the specified superclass.
+ /// Classes require a parameterless constructor. + ///
+ /// + /// + public static List GetListOfInstantiatedSubclasses() where T : class + { + var baseType = typeof(T); + var instances = new List(); + var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + + foreach (var assembly in assemblies) + { + if (assembly.IsDynamic) continue; + + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException e) + { + types = e.Types.Where(t => t != null).ToArray()!; + } + catch (Exception) + { + continue; + } + + foreach (var type in types) + { + if (type is not { IsClass: true, IsAbstract: false } || !baseType.IsAssignableFrom(type)) continue; + try + { + if (type.GetConstructor(Type.EmptyTypes) != null && Activator.CreateInstance(type) is T instance) + instances.Add(instance); + } + catch (Exception ex) + { + BaseLibMain.Logger.Warn($"Mod assembly {assembly.GetName().Name} failed to instantiate type {type.FullName} for base {baseType.Name}. Error: {ex.Message}"); + } + } + } + + return instances; + } } \ No newline at end of file From d588eed360b4b5a5fdb366b47ff8f2466477f0be Mon Sep 17 00:00:00 2001 From: quipsol <245758987+quipsol@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:43:26 +0200 Subject: [PATCH 2/6] Squashed commit of the following: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit c95f0ec9a0c95cf1cb942693903a328e17c61174 Merge: a569501 fc58de3 Author: Alchyr <39511935+Alchyr@users.noreply.github.com> Date: Fri Jul 3 09:59:09 2026 +0000 Merge pull request #321 from Alchyr/develop 3.3.4 commit fc58de31aa563580bdef188b12b0625a3e45ade3 Author: Alchyr Date: Fri Jul 3 02:55:37 2026 -0700 3.3.4 commit b9e0308f4a2b70b46170ce69ce35c44de0230fcf Author: Alchyr Date: Fri Jul 3 02:53:59 2026 -0700 fix for main branch temporary powers when compiling against beta branch commit a5695011dfcb45ef7c703f1660be260439b18e84 Merge: 323fbcb 3ee7fc3 Author: Alchyr <39511935+Alchyr@users.noreply.github.com> Date: Fri Jul 3 06:50:08 2026 +0000 Merge pull request #319 from Alchyr/develop v3.3.3 commit 3ee7fc31ed1139c830e2640bb444dd36fc168862 Author: Alchyr Date: Thu Jul 2 23:39:38 2026 -0700 3.3.3 commit d0c71eae901986ec66c44c0de0486eca30a6ab00 Author: Alchyr Date: Thu Jul 2 23:30:06 2026 -0700 update notes commit 0378af125b879e650639765f87af777227a60748 Author: Alchyr Date: Thu Jul 2 23:29:36 2026 -0700 base damage modification patches (wip) commit 8adccdc72b0b700c4875dc22f91a14f0f5434f44 Author: Alchyr Date: Thu Jul 2 23:29:21 2026 -0700 ArgIndex extension method commit 47dee1e9bf9b67409d053eddbe128c59181a6ff2 Author: Alchyr Date: Thu Jul 2 23:28:57 2026 -0700 update card modifier class commit 978d665c788264170348d584315b2f9e5d4dba03 Author: Alchyr Date: Thu Jul 2 23:28:50 2026 -0700 temporarily disable card reward serialization patch commit ac5a874088fae17217203a2b85145b9feb364140 Author: Alchyr Date: Thu Jul 2 23:28:32 2026 -0700 avoid referencing inequality operator (breaks main) commit e8ed28b53b08b415b7a4f2733f00f9215f690ec3 Author: Alchyr Date: Thu Jul 2 23:28:20 2026 -0700 beta compatibility commit bb743c9eabb8ac7c53e8a731ac55a8b6f7bfd39d Author: Alchyr Date: Thu Jul 2 23:28:08 2026 -0700 more detailed fail patch comment commit 8fe11cd8d0fd22f420fe9281492c5794c032c9f1 Author: Alchyr Date: Thu Jul 2 23:27:58 2026 -0700 fix extra tooltip patches commit f13d27c7ebb098ae624dd47188dcbac85221a223 Author: Alchyr Date: Thu Jul 2 23:27:47 2026 -0700 allow null owner for dynamic var source commit 8e0b40c7c56c8485b622277dd20a77aa2936172c Author: Alchyr Date: Thu Jul 2 23:27:31 2026 -0700 make patch slightly more generic commit a9dec684a2f98b8ce36993851ea99c1eb5d0919c Author: Alchyr Date: Thu Jul 2 22:52:10 2026 -0700 adjustments to patch util commit 44f4d3e559a8f1ea88a3b8051abdade4b6986cfc Author: Alchyr Date: Thu Jul 2 22:32:29 2026 -0700 fix play destination patches for beta commit 8860cbd9b8b9cf662313ef049244d5eb88945716 Author: Alchyr Date: Thu Jul 2 22:32:01 2026 -0700 fix calculated var usage in CardBlock commit f7a57113327d6f9dd9edbb7b395cb8dba03582df Author: Alchyr Date: Thu Jul 2 22:31:46 2026 -0700 update ModInterop for beta compatibility commit 8ca14a7d68c01b37a9d69d449a0997dd0a6c7266 Author: Alchyr Date: Thu Jul 2 15:32:07 2026 -0700 beta compat cleanup commit 5a236a4a38a0e9512949f65535457feb96a29a41 Author: Alchyr Date: Thu Jul 2 15:31:52 2026 -0700 small adjust commit 7e16b8387c97c0b6dfbe312ef87f42cf34ec916e Author: Alchyr Date: Thu Jul 2 15:28:30 2026 -0700 beta compat cleanup commit 64235fdf41706b41c6469e55a5131eb746b1c4a2 Author: Alchyr Date: Thu Jul 2 15:27:26 2026 -0700 adjustment commit f78a625cf88d99bf266beb82f81b4db0c267ae6e Author: Alchyr Date: Thu Jul 2 14:46:52 2026 -0700 surround manually called patches in a try block commit c31efb12651e1b55dffc872306c8199d7519c2b7 Author: Alchyr Date: Thu Jul 2 14:46:36 2026 -0700 additional tips for relics and powers, and allow card mods to add tips commit 8a3ede710f606e0c34dc1cf7f0b8cc2b9a61bba7 Author: Alchyr Date: Thu Jul 2 14:44:29 2026 -0700 beta compat cleanup commit e8e8da8178573d272faeb1a107e028aea58b6f05 Author: Alchyr Date: Thu Jul 2 14:43:14 2026 -0700 small comment adjustment commit d13b235bf6c1169787fffae7834d87273a6c4128 Author: Alchyr Date: Thu Jul 2 14:43:00 2026 -0700 clean up old beta compatibility commit 424de34a8f34139e22abc82664caf321e1851c1a Merge: a111f1f 3197610 Author: Alchyr Date: Wed Jul 1 14:13:37 2026 -0700 Merge remote-tracking branch 'origin/develop' into develop commit a111f1ff731842429298aec06b0c6598657ca391 Author: Alchyr Date: Wed Jul 1 14:13:28 2026 -0700 disable log window on steam deck commit 3197610e363a0b4ea069c77b85cda57b6765c272 Merge: 77f6954 5e65533 Author: Alchyr <39511935+Alchyr@users.noreply.github.com> Date: Wed Jul 1 15:09:59 2026 +0000 Merge pull request #318 from 1939323749/loc/complete-zhs Complete Chinese (zhs) localization commit 5e65533471a88cb6902aef4ca3d3fad92cc12e21 Author: snowlie <1939323749@qq.com> Date: Wed Jul 1 14:33:03 2026 +0800 Complete Chinese (zhs) localization Adds the missing zhs tables (card_keywords, card_selection, gameplay_ui) and the remaining settings_ui keys, matching the game's official Chinese terminology (e.g. Transform=变化, Deck=牌组, pile names). commit 77f69548e1951a2d8a1b083c8256f2887519c10f Merge: 716a530 01f53c2 Author: Alchyr <39511935+Alchyr@users.noreply.github.com> Date: Wed Jul 1 06:23:15 2026 +0000 Merge pull request #317 from SpaD-Overolls/patch-1 change Purge description's "deck" to (gold) "Deck" commit 8c92469191446afcc35d9bc6af0c2494026ba8b4 Author: Alchyr Date: Tue Jun 30 22:56:05 2026 -0700 add type safe generic extension method to retrieve dynamic vars commit 01f53c2cda99005bcf9ecbdd806b5c627d3be400 Author: SpaD-Overolls <142741667+SpaD-Overolls@users.noreply.github.com> Date: Wed Jul 1 12:01:04 2026 +0800 change (white) "deck" to (gold) "Deck" change "deck" to "[gold]Deck[/gold]" to stay consistent with the basegame's usage of the term commit 7538bf9e5fc02d427bb51e3c152973cec3320785 Merge: f6569af 716a530 Author: Alchyr Date: Tue Jun 30 03:47:03 2026 -0700 Merge remote-tracking branch 'origin/develop' into develop commit 716a5303bfbc72f2dafdd50bf6f59f424a3a6246 Merge: d058d65 4805ee5 Author: Alchyr <39511935+Alchyr@users.noreply.github.com> Date: Tue Jun 30 10:46:54 2026 +0000 Merge pull request #316 from 1939323749/fix/autoplay-custom-target Add CardCmd.AutoPlay fallback for custom single-target types commit 4805ee54226883919187ff78ee018ba6e02503dc Author: snowlie <1939323749@qq.com> Date: Tue Jun 30 13:17:57 2026 +0800 Add CardCmd.AutoPlay fallback for custom single-target types AutoPlay only randomized a target for AnyEnemy/AnyAlly, so an auto-played custom single-target card with no target was played with null and errored (#312). Adds a defensive prefix that picks a random valid target via the registered predicate; any problem leaves the target untouched so the original logic runs unchanged. commit f6569af38f3272a15685f177ed904a861bffc663 Author: Alchyr Date: Sat Jun 27 15:49:01 2026 -0700 small cleanup commit fb570b5ae8fd850a315a4c49625e11436d792fc4 Author: Alchyr Date: Sat Jun 27 15:48:43 2026 -0700 update WithTip comments commit d058d65175edb01fb3396594245056be6defaf79 Merge: 69e9ba9 b47e1d4 Author: Alchyr <39511935+Alchyr@users.noreply.github.com> Date: Fri Jun 26 23:12:14 2026 +0000 Merge pull request #310 from lamali292/german-loc Add German localization for gameplay_ui.json commit b47e1d4c79ac5affcd2fc5f039c857c08a9564e8 Author: lamali Date: Fri Jun 26 23:32:24 2026 +0200 Add German localization for gameplay_ui.json commit 69e9ba9218491001e216fe2f48b9dfc25b080734 Author: Alchyr Date: Tue Jun 23 11:51:30 2026 -0700 fix audio play overloads commit 46041ec7c81f1b582431581a48ed29fb47c546af Author: Alchyr Date: Tue Jun 23 01:27:08 2026 -0700 adjust csproj to copy to workshop workspace commit d753eaeec8704d2cc8a05cba354b3518f8f2e85c Merge: 3533fe9 8df7063 Author: Alchyr Date: Tue Jun 23 01:11:55 2026 -0700 Merge remote-tracking branch 'origin/develop' into develop commit 3533fe9303474847825cc7570d62576e99056585 Author: Alchyr Date: Tue Jun 23 01:11:26 2026 -0700 add cloning option to spirefields on models commit 8df706379020fc78534ccef16bf36b5d7f7115ed Merge: 8d52d02 91a78e6 Author: Alchyr <39511935+Alchyr@users.noreply.github.com> Date: Sun Jun 21 22:13:12 2026 +0000 Merge pull request #305 from exscape/modconfig-buttons-only Mod config: allow configs with only buttons commit 91a78e63033135cec8b53a36849687209502b5b7 Author: Aeluwas Date: Sun Jun 21 23:43:33 2026 +0200 Mod config: allow configs with only buttons --- Abstracts/CardModifier.cs | 163 ++++++++++++++---- Abstracts/ConstructedCardModel.cs | 14 +- Abstracts/CustomTemporaryPowerModel.cs | 7 +- Audio/ModAudio.cs | 6 +- BaseLib.csproj | 27 ++- BaseLib.json | 2 +- BaseLib/localization/deu/gameplay_ui.json | 7 + BaseLib/localization/eng/card_keywords.json | 4 +- BaseLib/localization/zhs/card_keywords.json | 4 + BaseLib/localization/zhs/card_selection.json | 8 + BaseLib/localization/zhs/gameplay_ui.json | 7 + BaseLib/localization/zhs/settings_ui.json | 7 + BaseLibMain.cs | 13 +- Cards/Variables/CustomCalculatedVar.cs | 4 +- Cards/Variables/PersistVar.cs | 3 +- Config/ModConfig.cs | 20 ++- Config/ModConfigRegistry.cs | 2 +- Config/UI/NModConfigSubmenu.cs | 2 +- ConsoleCommands/OpenLogWindow.cs | 7 + Extensions/CardExtensions.cs | 20 ++- Extensions/DynamicVarExtensions.cs | 13 +- Extensions/DynamicVarSetExtensions.cs | 43 ++++- Extensions/HarmonyExtensions.cs | 2 +- Extensions/MethodBaseExtensions.cs | 16 ++ Hooks/IAfterCardDowngraded.cs | 5 +- Notes.txt | 16 +- Patches/Content/CustomEnums.cs | 4 +- Patches/Content/CustomPilePatches.cs | 14 +- Patches/Features/AutoPlayCustomTargetPatch.cs | 51 ++++++ Patches/Features/CustomTargetType.cs | 8 +- Patches/Features/ExhaustivePatch.cs | 56 ++++-- Patches/Features/ModInteropPatch.cs | 104 ++++++++--- Patches/Features/PersistPatch.cs | 32 ++-- Patches/Features/PurgePatch.cs | 61 +++++-- .../Fixes/AnyPlayerCardTargetingPatches.cs | 8 +- .../Fixes/CardRewardSerializationPatches.cs | 6 +- Patches/Hooks/MaxHandSizePatches.cs | 6 +- Patches/Hooks/ModifyBaseDamagePatches.cs | 106 ++++++++++++ Patches/Localization/ExtraTooltips.cs | 44 ++++- Patches/Localization/ModelLocPatch.cs | 2 +- Patches/UI/HealthBarForecastPatch.cs | 6 +- Patches/Utils/SelfApplyDebuffPatch.cs | 2 +- Utils/BetaMainCompatibility.cs | 64 +++---- Utils/CommonActions.cs | 126 +++++++------- Utils/DynamicVarSource.cs | 13 +- Utils/Patching/InstructionMatcher.cs | 113 ++++++++++-- Utils/Patching/InstructionPatcher.cs | 51 +++++- Utils/SpireField.cs | 141 ++++++++++++++- Utils/TooltipSource.cs | 2 +- 49 files changed, 1144 insertions(+), 298 deletions(-) create mode 100644 BaseLib/localization/deu/gameplay_ui.json create mode 100644 BaseLib/localization/zhs/card_keywords.json create mode 100644 BaseLib/localization/zhs/card_selection.json create mode 100644 BaseLib/localization/zhs/gameplay_ui.json create mode 100644 Extensions/MethodBaseExtensions.cs create mode 100644 Patches/Features/AutoPlayCustomTargetPatch.cs create mode 100644 Patches/Hooks/ModifyBaseDamagePatches.cs diff --git a/Abstracts/CardModifier.cs b/Abstracts/CardModifier.cs index a65366b7..89cdb60d 100644 --- a/Abstracts/CardModifier.cs +++ b/Abstracts/CardModifier.cs @@ -8,12 +8,14 @@ using MegaCrit.Sts2.Core.Entities.Cards; using MegaCrit.Sts2.Core.Entities.Creatures; using MegaCrit.Sts2.Core.GameActions.Multiplayer; +using MegaCrit.Sts2.Core.HoverTips; using MegaCrit.Sts2.Core.Localization; using MegaCrit.Sts2.Core.Localization.DynamicVars; using MegaCrit.Sts2.Core.Modding; using MegaCrit.Sts2.Core.Models; using MegaCrit.Sts2.Core.Multiplayer.Serialization; using MegaCrit.Sts2.Core.Saves.Runs; +using MegaCrit.Sts2.Core.ValueProps; namespace BaseLib.Abstracts; @@ -21,12 +23,25 @@ namespace BaseLib.Abstracts; /// A model that is attached to a card to modify its behavior. /// Receives all combat hooks, and is capable of modifying the card's description. /// More features to be added in the future. -/// TODO - base value modification like enchants/afflictions +/// TODO - in progress - base value modification like enchants/afflictions +/// Passive cost modification? Without calling cost modification methods; works as a "modifier" in EnergyCost /// public abstract class CardModifier : AbstractModel, IComparable { + private static readonly NotNullSpireField> _modifiers = + new NotNullSpireField>(() => []) + .CopyOnClone((src, dst, modifiers) => + { + foreach (var modifier in modifiers) + { + var cloneModifier = (CardModifier) modifier.MutableClone(); + dst.AddModifier(cloneModifier); + cloneModifier.AfterClonedOnCard(dst); + } + }); + /// - /// Obtains a new instance of a CardModifier from ModelDb using . + /// Obtains a new instance of a CardModifier from ModelDb using . /// /// /// @@ -35,7 +50,7 @@ public static T Get() where T : CardModifier return ModelDb.CardModifier(); } - public static void RegisterSave() + internal static void RegisterSave() { ExtendedSaveTypes.RegisterListSaveType(); ExtendedSaveTypes.RegisterDictionarySaveType(); @@ -70,6 +85,11 @@ public static void RegisterSave() return saves; }); } + + private static void LoadModifierSaves(CardModel card, List? modifiers) + { + _modifiers[card] = modifiers?.Select(mod => mod.ToRealMod(card)).ToList() ?? []; + } public sealed class ModifierSave : IPacketSerializable { @@ -77,7 +97,8 @@ public static ModifierSave FromModifier(CardModifier modifier) { var save = new ModifierSave() { - Id = modifier.Id + Id = modifier.Id, + Amount = modifier.Amount }; modifier.StoreSaveData(save); return save; @@ -87,11 +108,13 @@ public CardModifier ToRealMod(CardModel owner) { var mod = (CardModifier) ModelDb.GetById(Id!).MutableClone(); mod.Owner = owner; + mod.Amount = Amount; mod.LoadSaveData(this); return mod; } public ModelId? Id { get; set; } + public int Amount { get; set; } public Dictionary IntProperties { get; set; } = []; public Dictionary AdditionalProperties { get; set; } = []; @@ -99,12 +122,16 @@ public CardModifier ToRealMod(CardModel owner) public void Serialize(PacketWriter writer) { writer.WriteModelEntry(Id!); + + writer.WriteInt(Amount); + writer.WriteInt(IntProperties.Count); foreach (var entry in IntProperties) { writer.WriteString(entry.Key); writer.WriteInt(entry.Value); } + writer.WriteInt(AdditionalProperties.Count); foreach (var entry in AdditionalProperties) { @@ -118,6 +145,8 @@ public void Deserialize(PacketReader reader) { Id = reader.ReadModelIdAssumingType(); + Amount = reader.ReadInt(); + int capacity = reader.ReadInt(); IntProperties = new(capacity); for (int index = 0; index < capacity; ++index) @@ -139,6 +168,7 @@ public void Deserialize(PacketReader reader) /// /// Store values that must be saved in IntProperties or AdditionalProperties. + /// Override this and if you need to save additional information besides . /// public virtual void StoreSaveData(ModifierSave save) { @@ -146,20 +176,13 @@ public virtual void StoreSaveData(ModifierSave save) } /// /// Loads saved values into a new instance of this modifier. + /// Override this and if you need to save additional information besides . /// public virtual void LoadSaveData(ModifierSave save) { } - private static void LoadModifierSaves(CardModel card, List? modifiers) - { - _modifiers[card] = modifiers?.Select(mod => mod.ToRealMod(card)).ToList() ?? []; - } - - - private static readonly SpireField> _modifiers = new(() => []); - /// /// Gets the list of modifiers on a card. /// This list is read-only and cannot be modified. @@ -180,6 +203,16 @@ public static void AddModifier(CardModel card) where T : CardModifier AddModifier(card, ModelDb.CardModifier(true)); } + /// + /// Adds a card modifier to a card with a specified amount. + /// + public static void AddModifier(CardModel card, int amount) where T : CardModifier + { + var mod = ModelDb.CardModifier(true); + mod.Amount = amount; + AddModifier(card, mod); + } + /// /// Adds a card modifier to a card. The modifier being applied should be a mutable instance. /// Use the overload with a generic parameter if you don't need to set up the modifier before application. @@ -190,6 +223,9 @@ public static void AddModifier(CardModel card, CardModifier modifier) modifier.ApplyInternal(card); } + /// + /// Remove a specific CardModifier instance from a card. + /// public static bool RemoveModifier(CardModel card, CardModifier modifier) { return modifier.RemoveInternal(card); @@ -236,8 +272,22 @@ static CardModifier() /// Mostly unused; overridden just in case. /// public override bool ShouldReceiveCombatHooks => Owner?.ShouldReceiveCombatHooks ?? false; - private DynamicVarSet? _dynamicVars; + private DynamicVarSet? _dynamicVars; + + /// + /// An integer value attached to enchantments that is saved. + /// + public int Amount + { + get; + set + { + AssertMutable(); + field = value; + } + } + public CardModel? Owner { get; @@ -289,11 +339,8 @@ public DynamicVarSet DynamicVars if (_dynamicVars != null) return _dynamicVars; - if (Owner == null) - throw new InvalidOperationException("Attempted to access a card modifier's dynamic vars before it has an owner"); - _dynamicVars = new DynamicVarSet(CanonicalVars); - _dynamicVars.InitializeWithOwner(Owner); + _dynamicVars.InitializeWithOwner(this); return _dynamicVars; } } @@ -305,13 +352,15 @@ public DynamicVarSet DynamicVars protected virtual IEnumerable CanonicalVars => []; /// - /// Retrieves a from a card_modifiers.json table using this modifier's ID - /// and adds this modifier's dynamic variables to it. + /// Retrieves a from a card_modifiers.json table using this modifier's ID. + /// Adds this modifier's dynamic variables, , and attached card's TargetType to the loc. /// public virtual LocString GetLoc(string subKey = "description") { var loc = new LocString("card_modifiers", $"{Id.Entry}.{subKey}"); + loc.Add("Amount", Amount); DynamicVars.AddTo(loc); + loc.Add("TargetType", Owner == null ? "None" : Owner.TargetType.ToString()); return loc; } @@ -333,6 +382,14 @@ public virtual void ModifyDescriptionPost(Creature? target, ref string descripti } + /// + /// Receives the card's list of tips to add to. + /// + public virtual void AddTips(List tips) + { + + } + /// /// Called after the modifier is applied to a card, including when a card is copied. /// Due to nature of when this occurs, async combat effects should not occur here. @@ -359,6 +416,10 @@ public virtual void OnDowngrade() _dynamicVars.InitializeWithOwner(Owner!); } + /// + /// Called whenever the attached card updates its dynamic variable previews to update the modifier's dynamic vars. + /// Can be overridden if some custom behavior to update display information is needed. + /// public virtual void UpdateDynamicVarPreview(CardPreviewMode previewMode, Creature? target, bool runGlobalHooks) { foreach (var dynVar in DynamicVars.Values) @@ -375,35 +436,63 @@ public virtual void AfterClonedOnCard(CardModel card) { } + + /// + /// Functions like an EnchantmentModel's EnchantDamageAdditive. + /// Add to the amount of damage that this modifier's card does. + /// This hook runs BEFORE all other damage modification hooks. + /// NOT YET FULLY FUNCTIONAL. + /// + /// The amount of damage that would be dealt. + /// ValueProp for damage. + /// Amount of damage to be added. + public virtual decimal ModifyBaseDamageAdditive(decimal originalDamage, ValueProp props) => 0; + + /// + /// Functions like an EnchantmentModel's EnchantDamageMultiplicative. + /// Multiply the amount of damage that this modifier's card does. + /// This hook runs BEFORE all other damage modification hooks. + /// NOT YET FULLY FUNCTIONAL. + /// + /// The amount of damage that would be dealt. + /// ValueProp for damage. + /// Amount that the damage should be multiplied by. + public virtual decimal ModifyBaseDamageMultiplicative(decimal originalDamage, ValueProp props) => 1; + + /// + /// Functions like an EnchantmentModel's EnchantBlockAdditive. + /// Add to the amount of block that this modifier's card gains. + /// This hook runs BEFORE all other block modification hooks. + /// NOT YET FUNCTIONAL. + /// + /// The original amount of block that would be gained. + /// The amount to add to the block gain. + public virtual decimal ModifyBaseBlockAdditive(decimal originalBlock) => 0M; + + /// + /// Functions like an EnchantmentModel's EnchantBlockMultiplicative. + /// Modify the amount of block that this modifier's card gains. + /// This hook runs BEFORE all other block modification hooks. + /// NOT YET FUNCTIONAL. + /// + /// The original amount of block that would be gained. + /// The amount to multiply the block gain by. + public virtual decimal ModifyBaseBlockMultiplicative(decimal originalBlock) => 1M; /// /// Called after the card's OnPlay method is called. Occurs before normal AfterCardPlayed hook. /// - public virtual Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay) - { - return Task.CompletedTask; - } + public virtual Task OnPlay(PlayerChoiceContext choiceContext, CardPlay cardPlay) => Task.CompletedTask; int IComparable.CompareTo(CardModifier? other) { return Priority.CompareTo(other?.Priority ?? 0); } -} -[HarmonyPatch(typeof(AbstractModel), nameof(AbstractModel.MutableClone))] -static class CloneModifiers { - [HarmonyPostfix] - static void ModifyResult(AbstractModel __instance, AbstractModel __result) + /// Called when a mutable clone is created, after the standard MemberwiseClone creates the instance. + protected override void DeepCloneFields() { - if (__instance is CardModel card && __result is CardModel resultCard) - { - foreach (var modifier in CardModifier.Modifiers(card)) - { - var cloneModifier = (CardModifier) modifier.MutableClone(); - resultCard.AddModifier(cloneModifier); - cloneModifier.AfterClonedOnCard(resultCard); - } - } + _dynamicVars = DynamicVars.Clone(this); } } diff --git a/Abstracts/ConstructedCardModel.cs b/Abstracts/ConstructedCardModel.cs index 61fb5245..955c32b7 100644 --- a/Abstracts/ConstructedCardModel.cs +++ b/Abstracts/ConstructedCardModel.cs @@ -124,7 +124,7 @@ protected ConstructedCardModel WithHeal(int baseVal, int upgrade = 0) protected ConstructedCardModel WithPower(int baseVal, int upgrade = 0) where T : PowerModel { _constructedDynamicVars.Add(new PowerVar(baseVal).WithUpgrade(upgrade)); - _hoverTips.Add(new(_ => BetaMainCompatibility._HoverTipFactory.FromPower())); + _hoverTips.Add(new(_ => HoverTipFactory.FromPower())); return this; } @@ -134,7 +134,7 @@ protected ConstructedCardModel WithPower(int baseVal, int upgrade = 0) where protected ConstructedCardModel WithPower(string name, int baseVal, int upgrade = 0) where T : PowerModel { _constructedDynamicVars.Add(new PowerVar(name, baseVal).WithUpgrade(upgrade)); - _hoverTips.Add(new(_ => BetaMainCompatibility._HoverTipFactory.FromPower())); + _hoverTips.Add(new(_ => HoverTipFactory.FromPower())); return this; } @@ -329,7 +329,12 @@ protected ConstructedCardModel WithCostUpgradeBy(int amount) } /// - /// Can accept PowerModel, CardKeyword, CardModel, PotionModel, StaticHoverTip, EnchantmentModel + ///

Can accept a PowerModel, CardModel, PotionModel, or EnchantmentModel type

+ ///

Or a CardKeyword or StaticHoverTip enum value.

+ /// WithTip(typeof(StrikeIronclad)); + /// WithTip(CardKeyword.Exhaust); + /// WithTip(typeof(StrengthPower)); + /// WithTip(StaticHoverTip.Evoke); ///
/// /// @@ -340,7 +345,8 @@ protected ConstructedCardModel WithTip(TooltipSource tipSource) } /// - /// Adds multiple hover tips to the card. + /// Adds dynamic hover tips to the card using a function based on the card instance. + /// Do not reference the card's fields directly; use the model received as a parameter. /// protected ConstructedCardModel WithTips(Func> multiTipSource) { diff --git a/Abstracts/CustomTemporaryPowerModel.cs b/Abstracts/CustomTemporaryPowerModel.cs index e55e8450..48e1b9c1 100644 --- a/Abstracts/CustomTemporaryPowerModel.cs +++ b/Abstracts/CustomTemporaryPowerModel.cs @@ -13,10 +13,15 @@ namespace BaseLib.Abstracts; +internal interface IBetaCompatTempPower +{ + void IgnoreNextInstance(); +} + /// /// A generic version of the base games Temporary Strength and Dexterity Power with small functionality improvements /// -public abstract class CustomTemporaryPowerModel : CustomPowerModel, ITemporaryPower, IAddDumbVariablesToPowerDescription +public abstract class CustomTemporaryPowerModel : CustomPowerModel, ITemporaryPower, IBetaCompatTempPower, IAddDumbVariablesToPowerDescription { private const string LocTurnEndBoolVar = "UntilEndOfOtherSideTurn"; diff --git a/Audio/ModAudio.cs b/Audio/ModAudio.cs index a37224d6..f30c741b 100644 --- a/Audio/ModAudio.cs +++ b/Audio/ModAudio.cs @@ -285,7 +285,7 @@ public class AutoModAudio(string folder) _sounds[path] = sound; } - return ModAudio.PlaySound(sound, volume, pitchVariation, basePitch); + return ModAudio.PlaySound(sound, volume, volumeMult, pitchVariation, basePitch); } /// Adjustment to volume in dB @@ -300,7 +300,7 @@ public class AutoModAudio(string folder) _sounds[path] = sound; } - return ModAudio.PlaySound(sound, volume, pitchVariation, basePitch); + return ModAudio.PlaySound(sound, volume, volumeMult, pitchVariation, basePitch); } /// Adjustment to volume in dB @@ -315,7 +315,7 @@ public class AutoModAudio(string folder) _sounds[path] = sound; } - return ModAudio.PlaySound(sound, volume, pitchVariation, basePitch); + return ModAudio.PlaySound(sound, volume, volumeMult, pitchVariation, basePitch); } } diff --git a/BaseLib.csproj b/BaseLib.csproj index 507c1116..906d037a 100644 --- a/BaseLib.csproj +++ b/BaseLib.csproj @@ -21,9 +21,9 @@ Alchyr.Sts2.BaseLib BaseLib-StS2 Mod for Slay the Spire 2 providing utilities and features for other mods. - 3.3.2 + 3.3.4 Alchyr - Bugfixes + Fix for temp powers on main branch c# $(NoWarn);NU5128;MSB3270 @@ -53,6 +53,11 @@ $(Sts2DataDir)/SmartFormat.dll false + + $(Sts2DataDir)/Steamworks.NET.dll + false + + @@ -81,13 +86,6 @@ - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - @@ -117,6 +115,12 @@ + + + + + + + + + + + diff --git a/BaseLib.json b/BaseLib.json index 9c6bc64d..6e96a35b 100644 --- a/BaseLib.json +++ b/BaseLib.json @@ -3,7 +3,7 @@ "name": "BaseLib", "author": "Alchyr", "description": "Modding utility for Slay the Spire 2", - "version": "v3.3.2", + "version": "v3.3.4", "has_pck": true, "has_dll": true, "dependencies": [], diff --git a/BaseLib/localization/deu/gameplay_ui.json b/BaseLib/localization/deu/gameplay_ui.json new file mode 100644 index 00000000..0741b3fb --- /dev/null +++ b/BaseLib/localization/deu/gameplay_ui.json @@ -0,0 +1,7 @@ +{ + "BASELIB-COMBAT_REWARD_CARD_UPGRADE": "Verbessere {cards:plural:eine Karte|{} Karten}.", + "BASELIB-COMBAT_REWARD_CARD_UPGRADE.selectionScreenPrompt": "Wähle eine Karte, die du verbesserst.", + "BASELIB-COMBAT_REWARD_RANDOM_CARD_UPGRADE": "Verbessere eine zufällige Karte.", + "BASELIB-COMBAT_REWARD_CARD_TRANSFORM": "Transformiere {Upgrade:und verbessere |}{cards:plural:eine Karte|{} Karten}", + "BASELIB-COMBAT_REWARD_CARD_TRANSFORM.selectionScreenPrompt": "Wähle {Amount:plural:eine Karte,|[blue]{}[/blue] Karten,} die du [gold]Transformierst[/gold]{Upgrade: und [gold]Verbesserst[/gold]|}." +} \ No newline at end of file diff --git a/BaseLib/localization/eng/card_keywords.json b/BaseLib/localization/eng/card_keywords.json index 066c2e6e..edf6c2d4 100644 --- a/BaseLib/localization/eng/card_keywords.json +++ b/BaseLib/localization/eng/card_keywords.json @@ -1,4 +1,4 @@ { "BASELIB-PURGE.title": "Purge", - "BASELIB-PURGE.description": "Removed from combat and your deck permanently." -} \ No newline at end of file + "BASELIB-PURGE.description": "Removed from combat and your [gold]Deck[/gold] permanently." +} diff --git a/BaseLib/localization/zhs/card_keywords.json b/BaseLib/localization/zhs/card_keywords.json new file mode 100644 index 00000000..53893dfd --- /dev/null +++ b/BaseLib/localization/zhs/card_keywords.json @@ -0,0 +1,4 @@ +{ + "BASELIB-PURGE.title": "即逝", + "BASELIB-PURGE.description": "从战斗和你的[gold]牌组[/gold]中永久移除。" +} diff --git a/BaseLib/localization/zhs/card_selection.json b/BaseLib/localization/zhs/card_selection.json new file mode 100644 index 00000000..338beb59 --- /dev/null +++ b/BaseLib/localization/zhs/card_selection.json @@ -0,0 +1,8 @@ +{ + "BASELIB-CHOOSE_GENERIC": "选择{Amount:plural:一张牌|[blue]{}[/blue]张牌}。", + "BASELIB-DRAW_PILE": "抽牌堆", + "BASELIB-DISCARD_PILE": "弃牌堆", + "BASELIB-EXHAUST_PILE": "消耗堆", + "BASELIB-HAND": "手牌", + "BASELIB-DECK": "牌组" +} diff --git a/BaseLib/localization/zhs/gameplay_ui.json b/BaseLib/localization/zhs/gameplay_ui.json new file mode 100644 index 00000000..46b70fa3 --- /dev/null +++ b/BaseLib/localization/zhs/gameplay_ui.json @@ -0,0 +1,7 @@ +{ + "BASELIB-COMBAT_REWARD_CARD_UPGRADE": "升级{cards:plural:一张牌|{}张牌}。", + "BASELIB-COMBAT_REWARD_CARD_UPGRADE.selectionScreenPrompt": "选择一张牌来升级。", + "BASELIB-COMBAT_REWARD_RANDOM_CARD_UPGRADE": "随机升级一张牌。", + "BASELIB-COMBAT_REWARD_CARD_TRANSFORM": "变化{Upgrade:并升级|}{cards:plural:一张牌|{}张牌}。", + "BASELIB-COMBAT_REWARD_CARD_TRANSFORM.selectionScreenPrompt": "选择{Amount:plural:一张牌:[blue]{}[/blue]张牌}来[gold]变化[/gold]{Upgrade:并[gold]升级[/gold]|}" +} diff --git a/BaseLib/localization/zhs/settings_ui.json b/BaseLib/localization/zhs/settings_ui.json index ff83f12f..c9e3ab4c 100644 --- a/BaseLib/localization/zhs/settings_ui.json +++ b/BaseLib/localization/zhs/settings_ui.json @@ -9,13 +9,20 @@ "BASELIB-SHOW_MOD_CONFIG_IN_MAIN_MENU.title": "在主菜单显示模组配置(需要重启)", "BASELIB-SHOW_MOD_CONFIG_IN_MAIN_MENU.hover.title": "在主菜单显示模组配置", "BASELIB-SHOW_MOD_CONFIG_IN_MAIN_MENU.hover.desc": "在游戏设置中始终会有一个入口。", + "BASELIB-GENERAL_SETTINGS.title": "通用设置", + "BASELIB-SFX_PLAYER_LIMIT.title": "音效数量上限", + "BASELIB-SFX_PLAYER_LIMIT.hover.desc": "通过 BaseLib 能同时播放的音效的最大数量。", "BASELIB-LOG_SECTION.title": "日志窗口/控制台", "BASELIB-OPEN_LOG_WINDOW_ON_STARTUP.title": "启动时打开日志窗口", "BASELIB-OPEN_LOG_WINDOW_ON_STARTUP.hover.desc": "在主菜单加载时自动打开 BaseLib 日志窗口。", + "BASELIB-OPEN_LOG_WINDOW_ON_ERROR.title": "出现错误时打开日志窗口", + "BASELIB-OPEN_LOG_WINDOW_ON_ERROR.hover.desc": "游戏出现错误时,自动打开日志窗口。", "BASELIB-LIMITED_LOG_SIZE.title": "日志历史大小", "BASELIB-LIMITED_LOG_SIZE.hover.desc": "游戏内日志窗口保留的最大行数。", + "BASELIB-LIMITED_LOG_SIZE.sliderFormat": "{0:0}行", "BASELIB-LOG_FONT_SIZE.title": "日志字体大小", "BASELIB-LOG_FONT_SIZE.hover.desc": "也可以在日志窗口中使用 Ctrl+滚轮进行设置。", + "BASELIB-LOG_FONT_SIZE.sliderFormat": "{0:0}像素", "BASELIB-HARMONY_DUMP_SECTION.title": "Harmony 补丁导出 (Dump)", "BASELIB-HARMONY_PATCH_DUMP_OUTPUT_PATH.title": "输出文件路径", "BASELIB-HARMONY_PATCH_DUMP_OUTPUT_PATH.placeholder": "绝对路径或 user://… (例如 user://baselib_harmony_patch_dump.log)", diff --git a/BaseLibMain.cs b/BaseLibMain.cs index cc0ab350..3b13882f 100644 --- a/BaseLibMain.cs +++ b/BaseLibMain.cs @@ -52,9 +52,16 @@ public static void Initialize() ModConfigRegistry.Register(ModId, new BaseLibConfig()); - TheBigPatchToCardPileCmdAdd.Patch(MainHarmony); - CustomBadgesPatch.Patch(MainHarmony); - ExtendedSavePatches.Patch(MainHarmony); + try + { + ExtendedSavePatches.Patch(MainHarmony); + TheBigPatchToCardPileCmdAdd.Patch(MainHarmony); + CustomBadgesPatch.Patch(MainHarmony); + } + catch (Exception e) + { + Logger.Error(e.ToString()); + } MainHarmony.TryPatchAll(assembly); diff --git a/Cards/Variables/CustomCalculatedVar.cs b/Cards/Variables/CustomCalculatedVar.cs index 231102d7..f8837260 100644 --- a/Cards/Variables/CustomCalculatedVar.cs +++ b/Cards/Variables/CustomCalculatedVar.cs @@ -60,13 +60,13 @@ public static decimal CalculateCustomVar(DynamicVar dynVar, DynamicVar baseVar, //Treated same as normal cards where calculation is disabled out of combat case EnchantmentModel enchant: mult = (!CombatManager.Instance.IsInProgress || - BetaMainCompatibility.Creature_.WrappedCombatState(enchant.Card.Owner.Creature) == null) ? 0 : + enchant.Card.Owner.Creature.CombatState == null) ? 0 : generalCalc?.Invoke(enchant, target) ?? throw new InvalidOperationException( $"{dynVar.GetType().Name} {dynVar.Name} does not have multiplier calc defined for enchantments in {owner.Id}"); return baseVar.BaseValue + extraVar.BaseValue * mult; case CardModifier modifier: mult = (!CombatManager.Instance.IsInProgress || - BetaMainCompatibility.Creature_.WrappedCombatState(modifier.Owner!.Owner.Creature) == null) ? 0 : + modifier.Owner!.Owner.Creature.CombatState == null) ? 0 : generalCalc?.Invoke(modifier, target) ?? throw new InvalidOperationException( $"{dynVar.GetType().Name} {dynVar.Name} does not have multiplier calc defined for card modifiers in {owner.Id}"); return baseVar.BaseValue + extraVar.BaseValue * mult; diff --git a/Cards/Variables/PersistVar.cs b/Cards/Variables/PersistVar.cs index 879a84e7..e8dd5136 100644 --- a/Cards/Variables/PersistVar.cs +++ b/Cards/Variables/PersistVar.cs @@ -1,5 +1,4 @@ using BaseLib.Extensions; -using BaseLib.Utils; using MegaCrit.Sts2.Core.Combat; using MegaCrit.Sts2.Core.Entities.Cards; using MegaCrit.Sts2.Core.Entities.Creatures; @@ -25,7 +24,7 @@ public override void UpdateCardPreview(CardModel card, CardPreviewMode previewMo public static int PersistCount(CardModel card, int basePersist) { int playCount = CombatManager.Instance.History.CardPlaysFinished.Count(entry => - BetaMainCompatibility.CardModel_.WrappedCombatState(card)?.HappenedThisTurn(entry) == true + entry.HappenedThisTurn(card.CombatState) && entry.CardPlay.Card == card); return Math.Max(0, basePersist - playCount); } diff --git a/Config/ModConfig.cs b/Config/ModConfig.cs index 707b85bb..92b30867 100644 --- a/Config/ModConfig.cs +++ b/Config/ModConfig.cs @@ -64,6 +64,8 @@ public abstract partial class ModConfig protected readonly List ConfigProperties = []; private readonly Dictionary _defaultValues = new(); + private bool _hasVisibleSettings; + private bool _hasButton; public static class ModConfigLogger { @@ -122,15 +124,22 @@ public ModConfig(string? filename = null) } public bool HasSettings() => ConfigProperties.Count > 0; - public bool HasVisibleSettings() => - ConfigProperties.Any(p => p.GetCustomAttribute() == null); + public bool HasVisibleSettings() => _hasVisibleSettings; + /// + /// If true, this ModConfig will show up in the mod list. By default, a mod is shown if it has any visible setting + /// or button, but for a fully custom UI, there may be rare cases where you need to override this. + /// + public virtual bool VisibleInModList() => _hasVisibleSettings || _hasButton; private void CheckConfigProperties() { var configType = GetType(); + _hasVisibleSettings = false; + _hasButton = false; ConfigProperties.Clear(); + foreach (var property in configType.GetProperties()) { if (property.GetCustomAttribute() != null) continue; @@ -142,7 +151,14 @@ private void CheckConfigProperties() } ConfigProperties.Add(property); + + if (property.GetCustomAttribute() == null) + _hasVisibleSettings = true; } + + _hasButton = configType + .GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance) + .Any(m => m.GetCustomAttribute() != null); } public T? GetDefaultValue(string propertyName) diff --git a/Config/ModConfigRegistry.cs b/Config/ModConfigRegistry.cs index e08a6f37..e44752d5 100644 --- a/Config/ModConfigRegistry.cs +++ b/Config/ModConfigRegistry.cs @@ -6,7 +6,7 @@ public static class ModConfigRegistry public static void Register(string modId, ModConfig config) { - if (!config.HasSettings()) return; + if (!config.HasSettings() && !config.VisibleInModList()) return; config.ModId = modId; ModConfigs[modId] = config; diff --git a/Config/UI/NModConfigSubmenu.cs b/Config/UI/NModConfigSubmenu.cs index cd17312a..4d5f4aa8 100644 --- a/Config/UI/NModConfigSubmenu.cs +++ b/Config/UI/NModConfigSubmenu.cs @@ -122,7 +122,7 @@ private void InitializeModList() { var selfNodePath = new NodePath("."); - foreach (var modConfig in ModConfigRegistry.GetAll().Where(mod => mod.HasVisibleSettings())) + foreach (var modConfig in ModConfigRegistry.GetAll().Where(mod => mod.VisibleInModList())) { var modName = GetModTitle(modConfig); var modButton = new NModListButton(modName); diff --git a/ConsoleCommands/OpenLogWindow.cs b/ConsoleCommands/OpenLogWindow.cs index 61fefed2..d33263b8 100644 --- a/ConsoleCommands/OpenLogWindow.cs +++ b/ConsoleCommands/OpenLogWindow.cs @@ -5,6 +5,7 @@ using MegaCrit.Sts2.Core.Entities.Players; using MegaCrit.Sts2.Core.Helpers; using MegaCrit.Sts2.Core.Nodes; +using Steamworks; namespace BaseLib.ConsoleCommands; @@ -28,6 +29,12 @@ public static void OpenWindow(bool stealFocus) BaseLibMain.Logger.Info("OpenWindow called when not on main thread"); return; } + + if (SteamUtils.IsSteamRunningOnSteamDeck()) + { + BaseLibMain.Logger.Info("OpenWindow cancelled; log window disabled on steam deck"); + return; + } var instance = NGame.Instance; if (instance == null) return; diff --git a/Extensions/CardExtensions.cs b/Extensions/CardExtensions.cs index 50e2d06c..29380242 100644 --- a/Extensions/CardExtensions.cs +++ b/Extensions/CardExtensions.cs @@ -23,12 +23,12 @@ public static List GetTargets(this CardModel card) switch (card.TargetType) { case TargetType.AllAllies: - var state = BetaMainCompatibility.CardModel_.WrappedCombatState(card); + var state = card.CombatState; return state?.PlayerCreatures.Where(c => c is { IsAlive: true }).ToList() ?? []; case TargetType.AllEnemies: - return BetaMainCompatibility.CardModel_.WrappedCombatState(card)?.HittableEnemies.ToList() ?? []; + return card.CombatState?.HittableEnemies.ToList() ?? []; case TargetType.RandomEnemy: - var allTargets = BetaMainCompatibility.CardModel_.WrappedCombatState(card)?.HittableEnemies; + var allTargets = card.CombatState?.HittableEnemies; if (allTargets == null || allTargets.Count == 0) return []; var target = card.Owner.RunState.Rng.CombatTargets.NextItem(allTargets); if (target == null) return []; @@ -40,7 +40,7 @@ public static List GetTargets(this CardModel card) default: if (CustomTargetType.IsCustomMultiTargetType(card.TargetType)) { - state = BetaMainCompatibility.CardModel_.WrappedCombatState(card); + state = card.CombatState; return state?.Creatures.Where(c => CustomTargetType.CanMultiTarget(card.TargetType, c, card.Owner)).ToList() ?? []; } @@ -54,12 +54,22 @@ public static List GetTargets(this CardModel card) /// /// Convenience shortcut to . - /// Adds a modifier to a card. + /// Adds a modifier to a card. Use this method if you need to perform setup on a mutable instance of the modifier. + /// Otherwise, use . /// public static void AddModifier(this CardModel card, CardModifier modifier) { CardModifier.AddModifier(card, modifier); } + + /// + /// Convenience shortcut to . + /// Adds a card modifier to a card. + /// + public static void AddModifier(this CardModel card, int amount = 0) where T : CardModifier + { + CardModifier.AddModifier(card, amount); + } /// /// Get all s currently attached to a card. diff --git a/Extensions/DynamicVarExtensions.cs b/Extensions/DynamicVarExtensions.cs index d2d65185..f6c2e107 100644 --- a/Extensions/DynamicVarExtensions.cs +++ b/Extensions/DynamicVarExtensions.cs @@ -9,6 +9,7 @@ using MegaCrit.Sts2.Core.ValueProps; using BaseLib.Utils; using HarmonyLib; +using MegaCrit.Sts2.Core.Hooks; namespace BaseLib.Extensions; @@ -38,11 +39,17 @@ public static decimal CalculateBlock(this DynamicVar var, Creature creature, Val return amount; } - var combatState = BetaMainCompatibility.Creature_.CombatState.Get(creature); + var combatState = creature.CombatState; if (combatState == null) return amount; - amount = BetaMainCompatibility.Hook_.ModifyBlock - .Invoke(null, combatState, creature, amount, props, cardSource, cardPlay, null); + var enchantment = cardSource?.Enchantment; + if (enchantment != null) + { + amount += enchantment.EnchantBlockAdditive(amount); + amount *= enchantment.EnchantBlockMultiplicative(amount); + } + + amount = Hook.ModifyBlock(combatState, creature, amount, props, cardSource, cardPlay, out var modifiers); amount = Math.Max(amount, 0m); return amount; } diff --git a/Extensions/DynamicVarSetExtensions.cs b/Extensions/DynamicVarSetExtensions.cs index fd0b8912..e58499a2 100644 --- a/Extensions/DynamicVarSetExtensions.cs +++ b/Extensions/DynamicVarSetExtensions.cs @@ -5,11 +5,44 @@ namespace BaseLib.Extensions; public static class DynamicVarSetExtensions { - /// - /// Get a power var initialized with its default name. - /// - public static DynamicVar Power(this DynamicVarSet vars) where T : PowerModel + extension(DynamicVarSet vars) { - return vars[typeof(T).Name]; + /// + /// Get a power var initialized with its default name. + /// + public DynamicVar Power() where T : PowerModel + { + return vars[typeof(T).Name]; + } + + /// + /// Returns the first variable of the specified type, or a specific + /// named variable of the specified type, if a name is provided. + /// If no matching variable is found, an exception is thrown. + /// + public T Var(string? name = null) where T : DynamicVar + { + if (name != null) + { + if (vars.TryGetValue(name, out var resultVar)) + { + if (resultVar is T tResult) + { + return tResult; + } + throw new ArgumentException( + $"Found dynamic variable of type {resultVar.GetType().Name} instead of type {typeof(T).Name} with name {name}"); + } + throw new ArgumentException( + $"Failed to find dynamic variable of type {typeof(T).Name} with name {name}"); + } + + var maybeResult = vars.Select(entry => entry.Value).OfType().FirstOrDefault(); + if (maybeResult == null) + { + throw new ArgumentException($"No dynamic variables of type {typeof(T).Name} found."); + } + return maybeResult; + } } } \ No newline at end of file diff --git a/Extensions/HarmonyExtensions.cs b/Extensions/HarmonyExtensions.cs index 5ada5bc5..a3b924a1 100644 --- a/Extensions/HarmonyExtensions.cs +++ b/Extensions/HarmonyExtensions.cs @@ -34,7 +34,7 @@ public static bool TryPatchAll(this Harmony harmony, Assembly assembly, string? } catch (Exception e) { - BaseLibMain.Logger.Error(e.ToString()); + BaseLibMain.Logger.Error($"Patch {processor.Item1.FullName} failed;\n{e}"); ++failCount; } }); diff --git a/Extensions/MethodBaseExtensions.cs b/Extensions/MethodBaseExtensions.cs new file mode 100644 index 00000000..8901516b --- /dev/null +++ b/Extensions/MethodBaseExtensions.cs @@ -0,0 +1,16 @@ +using System.Reflection; +using MegaCrit.Sts2.Core.Extensions; + +namespace BaseLib.Extensions; + +public static class MethodBaseExtensions +{ + public static int ArgIndex(this MethodBase method, string paramName) + { + var paramInfos = method.GetParameters(); + var index = paramInfos.FirstIndex(param => param.Name == paramName); + if (index == -1) + throw new ArgumentException($"Failed to find parameter in method {method.Name} with name {paramName}."); + return index + (method.IsStatic ? 0 : 1); + } +} \ No newline at end of file diff --git a/Hooks/IAfterCardDowngraded.cs b/Hooks/IAfterCardDowngraded.cs index 6160cf85..46abf53a 100644 --- a/Hooks/IAfterCardDowngraded.cs +++ b/Hooks/IAfterCardDowngraded.cs @@ -1,4 +1,3 @@ -using BaseLib.Utils; using HarmonyLib; using MegaCrit.Sts2.Core.Models; using MegaCrit.Sts2.Core.Runs; @@ -26,9 +25,9 @@ private static class DowngradeHook [HarmonyPostfix] private static void Patch(CardModel __instance) { - var combatState = BetaMainCompatibility.CardModel_.WrappedCombatState(__instance); + var combatState = __instance.CombatState; var runState = __instance.Owner?.RunState ?? (combatState == null ? NullRunState.Instance : combatState.RunState); - foreach (var item in BetaMainCompatibility.RunState.IterateHookListeners.Invoke>(runState, combatState?.WrappedState) ?? []) + foreach (var item in runState.IterateHookListeners(combatState)) { (item as IAfterCardDowngraded)?.AfterCardDowngraded(__instance); } diff --git a/Notes.txt b/Notes.txt index dc3ee48c..a08741a8 100644 --- a/Notes.txt +++ b/Notes.txt @@ -3,17 +3,18 @@ actually a very nice introduction TODO -compendium options for custom pools +auto-scale compendium options for custom pools pools not linked to a character are added to the misc pool thing does the side scroll if there are too many options? Is the layout dynamic or does it need to be manually modified -config options for mods +Adjust transpiler patch implementations to make them more general +base damage/block modifier support for card model +(maybe something for generic dynamic var modification? but that'd be a lot more complicated.) +WhatMod -Suggested modification: -up to 3 rows. If 3 rows is full, shrink and increase number of elements per row (and if shrunk enough add another row) - +CommonActions for Damage (general) PlayCardAction : allow _card.SpendResources to spend custom resources, and also pass that value to a custom OnPlayWrapper if the card is a CustomCard? @@ -41,7 +42,7 @@ Starts by checking state and jumping based on it (simpler state machine may simp Each state is a branch with 3.5 sections. 1 When state is initially reached (comes from ending of previous state) - - Get awaiter of async call + - Call async method of state (returns task) and get awaiter from task - If awaiter is done, skip to section 4. Otherwise, continue to section 2 2 Set up AsyncTaskMethodBuilder.AwaitUnsafeOnCompleted 3 Branch destination when MoveNext is called again after done awaiting. @@ -51,7 +52,8 @@ Each state is a branch with 3.5 sections. - Intermediate non-awaited code will also occur here. - (section 1 of next state) -State is only set if it needs to wait, in which case the method returns and waits for next execution (when current awaiter ends) +State is only set if it needs to wait, in which case the method returns and waits for next execution (when current awaiter ends). +When initially reaching a state, the current state will be -1. diff --git a/Patches/Content/CustomEnums.cs b/Patches/Content/CustomEnums.cs index 420cd939..b0efd7c3 100644 --- a/Patches/Content/CustomEnums.cs +++ b/Patches/Content/CustomEnums.cs @@ -1,7 +1,5 @@ using System.Numerics; using System.Reflection; -using System.Security.Cryptography; -using System.Text; using BaseLib.Abstracts; using BaseLib.Commands; using BaseLib.Extensions; @@ -18,7 +16,7 @@ namespace BaseLib.Patches.Content; /// Marks a field as intended to contain a new generated enum value. /// Certain types of enums have additional functionality. Currently: CardKeyword, PileType /// -/// This is relevant only if the field is intended to be a keyword. If not supplied, field name will be used. +/// This is currently relevant only if the field is intended to be a keyword. If not supplied, field name will be used. [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)] public sealed class CustomEnumAttribute(string? name = null) : Attribute { diff --git a/Patches/Content/CustomPilePatches.cs b/Patches/Content/CustomPilePatches.cs index 2729799b..c94b2c02 100644 --- a/Patches/Content/CustomPilePatches.cs +++ b/Patches/Content/CustomPilePatches.cs @@ -235,8 +235,11 @@ public class TheBigPatchToCardPileCmdAdd public static void Patch(Harmony harmony) { BaseLibMain.Logger.Info("Performing CustomPile patch"); - harmony.PatchAsyncMoveNext(AccessTools.Method(typeof(CardPileCmd), nameof(CardPileCmd.Add), - [typeof(IEnumerable), typeof(CardPile), typeof(CardPilePosition), typeof(AbstractModel), typeof(bool)]), + harmony.PatchAsyncMoveNext( + AccessTools.Method(typeof(CardPileCmd), nameof(CardPileCmd.Add), + [typeof(IEnumerable), typeof(CardPile), typeof(CardPilePosition), typeof(AbstractModel), typeof(bool), typeof(bool)]) + ?? AccessTools.Method(typeof(CardPileCmd), nameof(CardPileCmd.Add), + [typeof(IEnumerable), typeof(CardPile), typeof(CardPilePosition), typeof(AbstractModel), typeof(bool)]), out stateMachineType, transpiler: AccessTools.Method(typeof(TheBigPatchToCardPileCmdAdd), nameof(BigPatch))); } @@ -253,6 +256,11 @@ static List BigPatch(IEnumerable instructions) MethodInfo pileTypeGetter = AccessTools.PropertyGetter(typeof(CardPile), "Type"); return new InstructionPatcher(instructions) + /* + cardNode = NCard.FindOnTable(card); + bool flag1 = cardNode == null && targetPile.Type.IsCombatPile() && (isFullHandAdd || oldPile != null || targetPile.Type == PileType.Hand); + For piles that should be visible like the hand. + */ .Match(new InstructionMatcher() //patch createCardNode .ldfld(fullHandAdd) .brtrue_s() @@ -326,7 +334,7 @@ static List BigPatch(IEnumerable instructions) new CodeInstruction(OpCodes.Brtrue_S, updateVisualsLabel) ]) .Match(new InstructionMatcher() //get local index of tween - .callvirt(typeof(Node), nameof(Node.CreateTween)) + .call_any(typeof(Node), nameof(Node.CreateTween)) .ldc_i4_1() .callvirt(typeof(Tween), nameof(Tween.SetParallel)) .stloc_any() diff --git a/Patches/Features/AutoPlayCustomTargetPatch.cs b/Patches/Features/AutoPlayCustomTargetPatch.cs new file mode 100644 index 00000000..b58508b5 --- /dev/null +++ b/Patches/Features/AutoPlayCustomTargetPatch.cs @@ -0,0 +1,51 @@ +using BaseLib.Utils; +using HarmonyLib; +using MegaCrit.Sts2.Core.Commands; +using MegaCrit.Sts2.Core.Entities.Cards; +using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.Models; + +namespace BaseLib.Patches.Features; + +/// +/// only randomizes a target for the vanilla +/// and types, so a card with a custom single-target type that is +/// auto-played without a target ends up played with a null target and throws. This fills in the +/// missing fallback by picking a random valid target using the registered predicate. +/// +/// Fully defensive: it never blocks the original method, and any problem leaves the target untouched +/// so the vanilla path runs exactly as before. +/// +[HarmonyPatch(typeof(CardCmd), nameof(CardCmd.AutoPlay))] +internal static class AutoPlayCustomTargetPatch +{ + [HarmonyPrefix] + private static void PickRandomCustomTarget(CardModel card, ref Creature? target) + { + try + { + // Only step in when there is no target and the type is a registered custom single-target. + if (target != null || card == null) return; + if (!CustomTargetType.SingleTargeting.TryGetValue(card.TargetType, out var canTarget) || canTarget == null) + return; + + var player = card.Owner; + if (player?.RunState?.Rng == null) return; + + var combatState = card.CombatState; + if (combatState == null) return; + + var candidates = combatState.Creatures + .Where(creature => creature.IsAlive && canTarget(creature, player)) + .ToList(); + if (candidates.Count == 0) return; + + target = player.RunState.Rng.CombatTargets.NextItem(candidates); + } + catch (Exception e) + { + // Swallow everything: leaving target unchanged simply runs the original AutoPlay logic. + BaseLibMain.Logger.Warn($"AutoPlay custom-target fallback failed; using vanilla behavior. {e}"); + } + } +} diff --git a/Patches/Features/CustomTargetType.cs b/Patches/Features/CustomTargetType.cs index 3a0c4bb7..6e91da1a 100644 --- a/Patches/Features/CustomTargetType.cs +++ b/Patches/Features/CustomTargetType.cs @@ -204,12 +204,12 @@ private static void RegisterTargetTypes() CustomTargetType.RegisterMultiTargetType(CustomTargetType.AllLowestHpEnemies, (target) => target is { IsAlive: true, IsEnemy: true } - && target.CurrentHp == BetaMainCompatibility.Creature_.WrappedCombatState(target)!.Enemies + && target.CurrentHp == target.CombatState!.Enemies .Where(e => e.IsAlive) .Min(e => e.CurrentHp)); CustomTargetType.RegisterMultiTargetType(CustomTargetType.AllHighestHpEnemies, (target) => target is { IsAlive: true, IsEnemy: true} && - target.CurrentHp == BetaMainCompatibility.Creature_.WrappedCombatState(target)!.Enemies + target.CurrentHp == target.CombatState!.Enemies .Where(e => e.IsAlive) .Max(e => e.CurrentHp)); @@ -298,7 +298,7 @@ public static AttackCommand TargetingFiltered(this AttackCommand cmd, IEnumerabl cmd, new StrongBox>(list)); if (cmd.Attacker == null) return cmd; - AttackCommandCombatState.SetValue(cmd, BetaMainCompatibility.Creature_.CombatState.Get(cmd.Attacker)); + AttackCommandCombatState.SetValue(cmd, cmd.Attacker.CombatState); return cmd; } } @@ -390,7 +390,7 @@ private static async Task FilteredControllerTargeting( { var card = instance.Card; var cardNode = instance.CardNode; - if (card == null || BetaMainCompatibility.CardModel_.WrappedCombatState(card) == null || cardNode == null) + if (card?.CombatState == null || cardNode == null) { instance.CancelPlayCard(); return; diff --git a/Patches/Features/ExhaustivePatch.cs b/Patches/Features/ExhaustivePatch.cs index ac64bed2..2d6d238a 100644 --- a/Patches/Features/ExhaustivePatch.cs +++ b/Patches/Features/ExhaustivePatch.cs @@ -6,28 +6,62 @@ namespace BaseLib.Patches.Features; -[HarmonyPatch(typeof(CardModel))] +[HarmonyPatch] public static class ExhaustivePatch { - static MethodBase TargetMethod() + [HarmonyPatch(typeof(CardModel))] + static class OldExhaustivePatch { - var targetMethod = AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileTypeForCardPlay"); - if (targetMethod == null) - targetMethod = AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileType"); + static MethodInfo? TargetMethod = AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileTypeForCardPlay") + ?? AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileType"); + + static IEnumerable TargetMethods() + { + if (TargetMethod != null) yield return TargetMethod; + } - return targetMethod; + static bool Prepare() + { + return TargetMethod != null; + } + + [HarmonyPostfix] + static void ExhaustForExhaustive(CardModel __instance, ref PileType __result) + { + if (ShouldExhaustForExhaustive(__instance)) + { + __result = PileType.Exhaust; + } + } } - [HarmonyPostfix] - static void ExhaustForExhaustive(CardModel __instance, ref PileType __result) + [HarmonyPatch(typeof(CardModel))] + static class BetaExhaustivePatch { - if (ExhaustForExhaustive(__instance)) + private static MethodInfo? TargetMethod = + AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileTypeAndPositionForCardPlay"); + + static IEnumerable TargetMethods() + { + if (TargetMethod != null) yield return TargetMethod; + } + + static bool Prepare() + { + return TargetMethod != null; + } + + [HarmonyPostfix] + static void ExhaustForExhaustive(CardModel __instance, ref (PileType, CardPilePosition) __result) { - __result = PileType.Exhaust; + if (ShouldExhaustForExhaustive(__instance)) + { + __result = (PileType.Exhaust, CardPilePosition.Bottom); + } } } - static bool ExhaustForExhaustive(CardModel card) + static bool ShouldExhaustForExhaustive(CardModel card) { return GetExhaustive(card) == 1; } diff --git a/Patches/Features/ModInteropPatch.cs b/Patches/Features/ModInteropPatch.cs index 80a407eb..1a9730b7 100644 --- a/Patches/Features/ModInteropPatch.cs +++ b/Patches/Features/ModInteropPatch.cs @@ -1,6 +1,7 @@ using System.Reflection; using System.Reflection.Emit; using System.Runtime.CompilerServices; +using BaseLib.Extensions; using BaseLib.Utils.ModInterop; using BaseLib.Utils.Patching; using HarmonyLib; @@ -14,14 +15,43 @@ internal class ModInterop private static readonly BindingFlags ValidMemberFlags = BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance; private static readonly FieldInfo WrappedValueField = AccessTools.DeclaredField(typeof(InteropClassWrapper), nameof(InteropClassWrapper.Value)); - private readonly Dictionary _loadedIds; + private static readonly FieldInfo? AssemblyField = AccessTools.DeclaredField(typeof(Mod), "assembly"); + private static readonly FieldInfo? AssembliesField = AccessTools.DeclaredField(typeof(Mod), "assemblies"); + + private readonly Dictionary> _loadedIds; internal ModInterop() { BaseLibMain.Logger.Info("Generating interop methods and properties"); - _loadedIds = ModManager.GetLoadedMods() - .Where(mod => mod.manifest != null && mod.assembly != null) - .ToDictionary(mod => mod.manifest?.id ?? "", mod => mod.assembly); + //mod.assembly OR mod.assemblies + _loadedIds = []; + ModManager.GetLoadedMods() + .Where(mod => mod.manifest is not null) + .Do(CheckAssembly); + } + + private void CheckAssembly(Mod mod) + { + if (AssemblyField != null) + { + var assembly = (Assembly?) AssemblyField.GetValue(mod); + if (assembly != null) + { + _loadedIds[mod.manifest?.id ?? ""] = [assembly]; + } + } + else if (AssembliesField != null) + { + var assemblies = (List?) AssembliesField.GetValue(mod); + if (assemblies != null) + { + _loadedIds[mod.manifest?.id ?? ""] = [..assemblies]; + } + } + else + { + BaseLibMain.Logger.Info("Unable to find assemblies tied to mods."); + } } internal void ProcessType(Harmony harmony, Type t) @@ -29,10 +59,10 @@ internal void ProcessType(Harmony harmony, Type t) var modInterop = t.GetCustomAttribute(); if (modInterop == null) return; - if (!_loadedIds.TryGetValue(modInterop.ModId, out var assembly)) return; - if (assembly == null) + if (!_loadedIds.TryGetValue(modInterop.ModId, out var assemblies)) return; + if (assemblies.Count == 0) { - BaseLibMain.Logger.Error($"Cannot generate interop for mod {modInterop.ModId}, assembly not found"); + BaseLibMain.Logger.Error($"Cannot generate interop for mod {modInterop.ModId}, no assemblies found"); return; } @@ -40,10 +70,10 @@ internal void ProcessType(Harmony harmony, Type t) var members = t.GetMembers(ValidMemberFlags); - GenInteropMembers(members, harmony, assembly, modInterop.Type, true); + GenInteropMembers(members, harmony, assemblies, modInterop.Type, true); } - private static bool GenInteropMembers(MemberInfo[] members, Harmony harmony, Assembly assembly, string? contextTargetType, bool requireStatic) + private static bool GenInteropMembers(MemberInfo[] members, Harmony harmony, List assemblies, string? contextTargetType, bool requireStatic) { foreach (var member in members) { @@ -51,17 +81,17 @@ private static bool GenInteropMembers(MemberInfo[] members, Harmony harmony, Ass { case PropertyInfo property: if (requireStatic && !(property.SetMethod?.IsStatic ?? true)) continue; - if (!GenInteropPropertyOrField(harmony, assembly, contextTargetType, property)) return false; + if (!GenInteropPropertyOrField(harmony, assemblies, contextTargetType, property)) return false; break; case MethodInfo method: if (requireStatic && !method.IsStatic) continue; if (method.IsConstructor || method.GetCustomAttribute() != null) continue; - if (!GenInteropMethod(harmony, assembly, contextTargetType, method)) return false; + if (!GenInteropMethod(harmony, assemblies, contextTargetType, method)) return false; break; case TypeInfo type: if (!type.IsAssignableTo(typeof(InteropClassWrapper))) continue; - if (!GenInteropType(harmony, assembly, contextTargetType, type)) return false; + if (!GenInteropType(harmony, assemblies, contextTargetType, type)) return false; break; } } @@ -69,7 +99,7 @@ private static bool GenInteropMembers(MemberInfo[] members, Harmony harmony, Ass return true; } - private static bool GenInteropType(Harmony harmony, Assembly targetAssembly, string? contextTargetType, TypeInfo type) + private static bool GenInteropType(Harmony harmony, List assemblies, string? contextTargetType, TypeInfo type) { var constructors = type.GetConstructors(); if (constructors.Length < 1) throw new Exception($"{type} must have at least one public constructor"); @@ -79,8 +109,18 @@ private static bool GenInteropType(Harmony harmony, Assembly targetAssembly, str try { - var targetType = Type.GetType($"{targetName}, {targetAssembly}") ?? - throw new Exception($"Type {targetName} not found in assembly {targetAssembly}"); + Type? targetType = null; + foreach (var targetAssembly in assemblies) + { + targetType = Type.GetType($"{targetName}, {targetAssembly}"); + if (targetType != null) break; + } + + if (targetType == null) + { + BaseLibMain.Logger.Error($"Failed to generate interop type; Type {targetName} not found in assemblies {assemblies.AsReadable()}"); + return false; + } foreach (var constructor in constructors) { @@ -97,7 +137,7 @@ private static bool GenInteropType(Harmony harmony, Assembly targetAssembly, str } BaseLibMain.Logger.Info($"Generated interop type {type.FullName}"); - return GenInteropMembers(type.GetMembers(ValidMemberFlags), harmony, targetAssembly, targetName, false); + return GenInteropMembers(type.GetMembers(ValidMemberFlags), harmony, assemblies, targetName, false); } catch (Exception e) { @@ -106,7 +146,7 @@ private static bool GenInteropType(Harmony harmony, Assembly targetAssembly, str } } - private static bool GenInteropMethod(Harmony harmony, Assembly targetAssembly, string? contextTargetType, MethodInfo method) + private static bool GenInteropMethod(Harmony harmony, List assemblies, string? contextTargetType, MethodInfo method) { var targetAttr = method.GetCustomAttribute(); @@ -115,8 +155,18 @@ private static bool GenInteropMethod(Harmony harmony, Assembly targetAssembly, s try { - var targetType = Type.GetType($"{type}, {targetAssembly}") ?? - throw new Exception($"Type {type} not found in assembly {targetAssembly}"); + Type? targetType = null; + foreach (var targetAssembly in assemblies) + { + targetType = Type.GetType($"{type}, {targetAssembly}"); + if (targetType != null) break; + } + + if (targetType == null) + { + BaseLibMain.Logger.Error($"Failed to generate interop type; Type {type} not found in assemblies {assemblies.AsReadable()}"); + return false; + } var methodParams = method.GetParameters().Select(p => p.ParameterType).ToArray(); var nonStaticParams = method.IsStatic ? [..methodParams.Skip(1)] : methodParams; @@ -187,7 +237,7 @@ private static bool GenInteropMethod(Harmony harmony, Assembly targetAssembly, s return true; } - private static bool GenInteropPropertyOrField(Harmony harmony, Assembly targetAssembly, string? contextTargetType, PropertyInfo property) + private static bool GenInteropPropertyOrField(Harmony harmony, List assemblies, string? contextTargetType, PropertyInfo property) { var targetAttr = property.GetCustomAttribute(); @@ -196,8 +246,18 @@ private static bool GenInteropPropertyOrField(Harmony harmony, Assembly targetAs try { - var targetType = Type.GetType($"{type}, {targetAssembly}") ?? - throw new Exception($"Type {type} not found in assembly {targetAssembly}"); + Type? targetType = null; + foreach (var targetAssembly in assemblies) + { + targetType = Type.GetType($"{type}, {targetAssembly}"); + if (targetType != null) break; + } + + if (targetType == null) + { + BaseLibMain.Logger.Error($"Failed to generate interop type; Type {type} not found in assemblies {assemblies.AsReadable()}"); + return false; + } var targetProperty = targetType.DeclaredProperty(name); if (targetProperty != null && targetProperty.PropertyType == property.PropertyType) diff --git a/Patches/Features/PersistPatch.cs b/Patches/Features/PersistPatch.cs index af0048bd..c849f1b9 100644 --- a/Patches/Features/PersistPatch.cs +++ b/Patches/Features/PersistPatch.cs @@ -12,34 +12,38 @@ namespace BaseLib.Patches.Features; [HarmonyPatch(typeof(CardModel))] public static class PersistPatch { - static MethodBase TargetMethod() + static MethodInfo? TargetMethod = + AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileTypeForCardPlay") + ?? AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileType") + ?? AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileTypeAndPositionForCardPlay"); + + static IEnumerable TargetMethods() { - var targetMethod = AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileTypeForCardPlay"); - if (targetMethod == null) - targetMethod = AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileType"); + if (TargetMethod != null) yield return TargetMethod; + } - return targetMethod; + static bool Prepare() + { + return TargetMethod != null; } [HarmonyTranspiler] static List AltDestination(IEnumerable instructions) { return new InstructionPatcher(instructions) - .Match(new InstructionMatcher() - .ldc_i4_4() - .ret() - .ldc_i4_3() - ) - .Insert([ - CodeInstruction.LoadArgument(0), - CodeInstruction.Call(typeof(PersistPatch), nameof(NormalOrPersist)), + .MatchFromEnd(new InstructionMatcher() + .ldc_i4_3() + ) + .Insert([ + CodeInstruction.LoadArgument(0), + CodeInstruction.Call(typeof(PersistPatch), nameof(NormalOrPersist)), ]); } //patched to be lower priority than exhaust static PileType NormalOrPersist(PileType dest, CardModel model) { - if (dest == PileType.Discard && IsPersist(model)) + if (dest == PileType.Discard && model.IsPersist()) { return PileType.Hand; } diff --git a/Patches/Features/PurgePatch.cs b/Patches/Features/PurgePatch.cs index 7892c49e..d7fbe74b 100644 --- a/Patches/Features/PurgePatch.cs +++ b/Patches/Features/PurgePatch.cs @@ -6,28 +6,65 @@ namespace BaseLib.Patches.Features; -[HarmonyPatch(typeof(CardModel))] +[HarmonyPatch] public class PurgePatch { - static MethodBase TargetMethod() + [HarmonyPatch(typeof(CardModel))] + static class OldPurgePatch { - var targetMethod = AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileTypeForCardPlay"); - if (targetMethod == null) - targetMethod = AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileType"); + static MethodInfo? TargetMethod = AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileTypeForCardPlay") + ?? AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileType"); + + static IEnumerable TargetMethods() + { + if (TargetMethod != null) yield return TargetMethod; + } + + static bool Prepare() + { + return TargetMethod != null; + } + + [HarmonyPrefix] + static bool GoAwayForever(CardModel __instance, ref PileType __result) + { + if (ShouldPurge(__instance)) + { + __result = PileType.None; + return false; + } - return targetMethod; + return true; + } } - [HarmonyPrefix] - static bool GoAwayForever(CardModel __instance, ref PileType __result) + [HarmonyPatch(typeof(CardModel))] + static class BetaPurgePatch { - if (ShouldPurge(__instance)) + private static MethodInfo? TargetMethod = + AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileTypeAndPositionForCardPlay"); + + static IEnumerable TargetMethods() { - __result = PileType.None; - return false; + if (TargetMethod != null) yield return TargetMethod; } - return true; + static bool Prepare() + { + return TargetMethod != null; + } + + [HarmonyPrefix] + static bool GoAwayForever(CardModel __instance, ref (PileType, CardPilePosition) __result) + { + if (ShouldPurge(__instance)) + { + __result = (PileType.None, CardPilePosition.Bottom); + return false; + } + + return true; + } } public static bool ShouldPurge(CardModel c) diff --git a/Patches/Fixes/AnyPlayerCardTargetingPatches.cs b/Patches/Fixes/AnyPlayerCardTargetingPatches.cs index b39d805f..d08674d1 100644 --- a/Patches/Fixes/AnyPlayerCardTargetingPatches.cs +++ b/Patches/Fixes/AnyPlayerCardTargetingPatches.cs @@ -288,8 +288,8 @@ private static async Task AnyPlayerControllerTargeting(NControllerCardPlay insta return; } - var combatState = BetaMainCompatibility.CardModel_.WrappedCombatState(card) - ?? BetaMainCompatibility.Creature_.WrappedCombatState(card.Owner.Creature); + var combatState = card.CombatState + ?? card.Owner.Creature.CombatState; if (combatState == null) { instance.CancelPlayCard(); @@ -376,8 +376,8 @@ private static void RandomAnyPlayer(CardModel card, ref Creature? target) if (!IsAnyPlayerMultiplayer(card) || target != null) return; - var combatState = BetaMainCompatibility.CardModel_.WrappedCombatState(card) - ?? BetaMainCompatibility.Creature_.WrappedCombatState(card.Owner.Creature); + var combatState = card.CombatState + ?? card.Owner.Creature.CombatState; if (combatState == null) return; diff --git a/Patches/Fixes/CardRewardSerializationPatches.cs b/Patches/Fixes/CardRewardSerializationPatches.cs index 30aee501..9aa74c75 100644 --- a/Patches/Fixes/CardRewardSerializationPatches.cs +++ b/Patches/Fixes/CardRewardSerializationPatches.cs @@ -1,4 +1,4 @@ -using System.Runtime.CompilerServices; +/*using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; using HarmonyLib; @@ -11,6 +11,8 @@ namespace BaseLib.Patches.Fixes; +// COMMENTED OUT TEMPORARILY; AN ACTUAL FIX IS NECESSARY + internal sealed class RewardExtData { [JsonPropertyName("flags")] @@ -281,4 +283,4 @@ private static CardReward RebuildCardReward( return new CardReward(poolOptions, save.OptionCount, player); } -} \ No newline at end of file +}*/ \ No newline at end of file diff --git a/Patches/Hooks/MaxHandSizePatches.cs b/Patches/Hooks/MaxHandSizePatches.cs index 7fd16b8d..3991a31b 100644 --- a/Patches/Hooks/MaxHandSizePatches.cs +++ b/Patches/Hooks/MaxHandSizePatches.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Reflection; using System.Reflection.Emit; using BaseLib.Hooks; @@ -214,7 +213,10 @@ static class CardPileCmd_Add_MaxHandSizePatch { static MethodInfo TargetMethod() => AccessTools.AsyncMoveNext( AccessTools.Method(typeof(CardPileCmd), nameof(CardPileCmd.Add), - [typeof(IEnumerable), typeof(CardPile), typeof(CardPilePosition), typeof(AbstractModel), typeof(bool)])); + [typeof(IEnumerable), typeof(CardPile), typeof(CardPilePosition), typeof(AbstractModel), typeof(bool), typeof(bool)]) + ?? AccessTools.Method(typeof(CardPileCmd), nameof(CardPileCmd.Add), + [typeof(IEnumerable), typeof(CardPile), typeof(CardPilePosition), typeof(AbstractModel), typeof(bool)]) + ); [HarmonyTranspiler] private static IEnumerable Transpiler(IEnumerable instructions, ILGenerator il, MethodBase original) diff --git a/Patches/Hooks/ModifyBaseDamagePatches.cs b/Patches/Hooks/ModifyBaseDamagePatches.cs new file mode 100644 index 00000000..087db50a --- /dev/null +++ b/Patches/Hooks/ModifyBaseDamagePatches.cs @@ -0,0 +1,106 @@ +using System.Reflection; +using BaseLib.Extensions; +using BaseLib.Utils.Patching; +using HarmonyLib; +using MegaCrit.Sts2.Core.Hooks; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.ValueProps; + +namespace BaseLib.Patches.Hooks; + +/// +/// Patches for modifying the base damage of cards. +/// +[HarmonyPatch] +public class ModifyBaseDamagePatches +{ + [HarmonyPatch(typeof(Hook), nameof(Hook.ModifyDamage))] + static class ModifyDamageCalc + { + //Prefix is fine here because it does not need to be added to the modifiers list; + //base value modifications function differently than the normal damage modification hook. + [HarmonyPrefix] + static void AdjustBaseAdditive( + ref decimal damage, + ValueProp props, + CardModel? cardSource, + ModifyDamageHookType modifyDamageHookType) + { + damage = ModifyBaseDamageAdditive(damage, props, cardSource, modifyDamageHookType); + } + + [HarmonyTranspiler] + static List AdjustBaseMultiplicative(IEnumerable code, MethodBase original) + { + return new InstructionPatcher(code) + .Match(new InstructionMatcher() + .ldargIndex(original.ArgIndex("damage")) + .stloc_any() + ) + .Step(-1) + .GetIndexOperand(out var damageLocal) + .Match(new InstructionMatcher() + .ldargIndex(original.ArgIndex("target"))) + .Insert([ + CodeInstruction.LoadLocal(damageLocal), + CodeInstruction.LoadArgument(original.ArgIndex("props")), + CodeInstruction.LoadArgument(original.ArgIndex("cardSource")), + CodeInstruction.LoadArgument(original.ArgIndex("modifyDamageHookType")), + CodeInstruction.Call(typeof(ModifyBaseDamagePatches), nameof(ModifyBaseDamageMultiplicative)), + CodeInstruction.StoreLocal(damageLocal) + ]); + } + } + + //TODO - Patches for damage/block vars, add to custom calculated vars. Updating for now for beta branch. + + /// + /// Applies additional modifiers for base damage addition. + /// + public static decimal ModifyBaseDamageAdditive(decimal damage, ValueProp props, CardModel? cardSource, ModifyDamageHookType modifyDamageHookType) + { + if (!modifyDamageHookType.HasFlag(ModifyDamageHookType.Additive)) return damage; + return ModifyBaseDamageAdditiveInternal(damage, props, cardSource); + } + + /// + /// Exists for convenience when patching in cases where additive modifiers are assumed to be applied. + /// + static decimal ModifyBaseDamageAdditiveInternal(decimal damage, ValueProp props, CardModel? cardSource) + { + if (cardSource != null) + { + foreach (var modifier in cardSource.GetModifiers()) + { + damage += modifier.ModifyBaseDamageAdditive(damage, props); + } + } + + return Math.Max(damage, 0); + } + + /// + /// Applies additional modifiers for base damage multiplication. + /// + public static decimal ModifyBaseDamageMultiplicative(decimal damage, ValueProp props, CardModel? cardSource, ModifyDamageHookType modifyDamageHookType) + { + if (!modifyDamageHookType.HasFlag(ModifyDamageHookType.Multiplicative)) return damage; + return ModifyBaseDamageMultiplicativeInternal(damage, props, cardSource); + } + + /// + /// Exists for convenience when patching in cases where multiplicative modifiers are assumed to be applied. + /// + static decimal ModifyBaseDamageMultiplicativeInternal(decimal damage, ValueProp props, CardModel? cardSource) + { + if (cardSource != null) + { + foreach (var modifier in cardSource.GetModifiers()) + { + damage *= modifier.ModifyBaseDamageMultiplicative(damage, props); + } + } + + return Math.Max(damage, 0); + } +} \ No newline at end of file diff --git a/Patches/Localization/ExtraTooltips.cs b/Patches/Localization/ExtraTooltips.cs index 21309cfc..f91c43a3 100644 --- a/Patches/Localization/ExtraTooltips.cs +++ b/Patches/Localization/ExtraTooltips.cs @@ -1,4 +1,5 @@ using BaseLib.Extensions; +using BaseLib.Utils; using BaseLib.Utils.Patching; using HarmonyLib; using MegaCrit.Sts2.Core.HoverTips; @@ -7,13 +8,14 @@ namespace BaseLib.Patches.Localization; /// -/// Adds additional tips to a card model's hovertips. +/// Adds additional tips to a model's hovertips. /// -[HarmonyPatch(typeof(CardModel), nameof(CardModel.HoverTips), MethodType.Getter)] +[HarmonyPatch] public class ExtraTooltips { + [HarmonyPatch(typeof(CardModel), nameof(CardModel.HoverTips), MethodType.Getter)] [HarmonyTranspiler] - static List AddCustomTips(IEnumerable instructions) + static List AddCustomCardTips(IEnumerable instructions) { return new InstructionPatcher(instructions) .Match(new InstructionMatcher() @@ -28,14 +30,46 @@ static List AddCustomTips(IEnumerable instruct CodeInstruction.Call(typeof(ExtraTooltips), "AddTips"), //add tips to list ]); } + + [HarmonyPatch(typeof(RelicModel), nameof(RelicModel.HoverTips), MethodType.Getter)] + [HarmonyPostfix] + static IEnumerable AddCustomRelicTips(IEnumerable __result, RelicModel __instance) + { + if (__result is ICollection tipCollection) + { + AddTipsGeneric(tipCollection, __instance); + } + + return __result; + } + + [HarmonyPatch(typeof(PowerModel), nameof(PowerModel.HoverTips), MethodType.Getter)] + [HarmonyPostfix] + static IEnumerable AddCustomPowerTips(IEnumerable __result, PowerModel __instance) + { + if (__result is ICollection tipCollection) + { + AddTipsGeneric(tipCollection, __instance); + } + + return __result; + } /// /// Adds additional tips to a card model's hovertips. /// public static void AddTips(List tips, CardModel card) { - //dynvar tips - foreach (var dynVar in card.DynamicVars.Values) + AddTipsGeneric(tips, card); + foreach (var cardMod in card.GetModifiers()) + { + cardMod.AddTips(tips); + } + } + + static void AddTipsGeneric(ICollection tips, DynamicVarSource dynVarSource) + { + foreach (var dynVar in dynVarSource.DynamicVars.Values) { var tip = DynamicVarExtensions.DynamicVarTips[dynVar]?.Invoke(dynVar); if (tip != null) tips.Add(tip); diff --git a/Patches/Localization/ModelLocPatch.cs b/Patches/Localization/ModelLocPatch.cs index e45fab5a..506023b5 100644 --- a/Patches/Localization/ModelLocPatch.cs +++ b/Patches/Localization/ModelLocPatch.cs @@ -45,7 +45,7 @@ static void AddModelLoc(Dictionary ____contentById) var table = locProvider.LocTable ?? CategoryToLocTable.GetValueOrDefault(content.Key.Category, null) - ?? throw new Exception("Override LocTable in your ILocalizationProvider."); + ?? throw new Exception($"Override LocTable in your ILocalizationProvider {content.Key}."); var locTable = LocManager.Instance.GetTable(table); var dict = LocDictionaryField.GetValue(locTable) as Dictionary ?? throw new Exception("Failed to get localization dictionary."); diff --git a/Patches/UI/HealthBarForecastPatch.cs b/Patches/UI/HealthBarForecastPatch.cs index 525bd876..530b1da1 100644 --- a/Patches/UI/HealthBarForecastPatch.cs +++ b/Patches/UI/HealthBarForecastPatch.cs @@ -48,7 +48,7 @@ private static void RefreshTextPostfix(NHealthBar __instance) private static void RefreshForegroundOverlay(NHealthBar healthBar) { var creature = healthBar._creature; - if (creature.CurrentHp <= 0 || BetaMainCompatibility.Creature_.ShowsInfiniteHp(creature)) + if (creature.CurrentHp <= 0 || creature.HpDisplay.IsInfinite()) { HideAllCustomSegments(healthBar); return; @@ -196,7 +196,7 @@ private static void RefreshMiddlegroundOverlay(NHealthBar healthBar) return; var creature = healthBar._creature; - if (creature.CurrentHp <= 0 || BetaMainCompatibility.Creature_.ShowsInfiniteHp(creature)) + if (creature.CurrentHp <= 0 || creature.HpDisplay.IsInfinite()) return; var hpMiddleground = healthBar._hpMiddleground; @@ -220,7 +220,7 @@ private static void RefreshTextOverlay(NHealthBar healthBar) return; var creature = healthBar._creature; - if (creature.CurrentHp <= 0 || BetaMainCompatibility.Creature_.ShowsInfiniteHp(creature)) + if (creature.CurrentHp <= 0 || creature.HpDisplay.IsInfinite()) return; var lethalColor = state.LastRender.LethalRightColor ?? state.LastRender.LethalLeftColor; diff --git a/Patches/Utils/SelfApplyDebuffPatch.cs b/Patches/Utils/SelfApplyDebuffPatch.cs index 9c930911..13c3c1f8 100644 --- a/Patches/Utils/SelfApplyDebuffPatch.cs +++ b/Patches/Utils/SelfApplyDebuffPatch.cs @@ -43,7 +43,7 @@ static async Task WrappedApplyTask(Task originalTask, PowerModel power, Creature await originalTask; // At this point, all the logic in the original function has been executed, including the part that sets SkipNextDurationTick to true. - if (BetaMainCompatibility.Creature_.WrappedCombatState(target)?.CurrentSide == CombatSide.Player + if (target.CombatState?.CurrentSide == CombatSide.Player && target.Side == CombatSide.Player && power is { Type: PowerType.Debuff, Applier.Side: CombatSide.Player } && (power is ICustomModel || power.Applier?.Monster is ICustomModel || power.Applier?.Player?.Character is ICustomModel || cardSource is ICustomModel)) { diff --git a/Utils/BetaMainCompatibility.cs b/Utils/BetaMainCompatibility.cs index 64256e21..461d3b8f 100644 --- a/Utils/BetaMainCompatibility.cs +++ b/Utils/BetaMainCompatibility.cs @@ -1,9 +1,7 @@ using System.Collections; using System.Reflection; using BaseLib.Extensions; -using Godot; using HarmonyLib; -using MegaCrit.Sts2.addons.mega_text; using MegaCrit.Sts2.Core.Combat; using MegaCrit.Sts2.Core.Combat.History; using MegaCrit.Sts2.Core.Commands; @@ -22,43 +20,36 @@ namespace BaseLib.Utils; -public class BetaMainCompatibility +/// +/// Utility methods to allow compatibility between main branch and beta branch. +/// +public static class BetaMainCompatibility { - public static class Renamed + /// + /// Compatibility extension method to use instead of FromCard that works on both main and beta branch. + /// + public static AttackCommand FromCardCompatibility(this AttackCommand command, CardModel card, CardPlay? cardPlay) { - [Obsolete("No longer differs between main and beta.")] - public static VariableReference> - LoadedMods = new(typeof(ModManager), "LoadedMods", "GetLoadedMods()"); - - [Obsolete("No longer differs between main and beta.")] - public static VariableReference - FontSize = new(typeof(ThemeConstants.Label), "FontSize", "fontSize"); - [Obsolete("No longer differs between main and beta.")] - public static VariableReference - Font = new(typeof(ThemeConstants.Label), "Font", "font"); - [Obsolete("No longer differs between main and beta.")] - public static VariableReference - LineSpacing = new(typeof(ThemeConstants.Label), "LineSpacing", "lineSpacing"); - [Obsolete("No longer differs between main and beta.")] - public static VariableReference - OutlineSize = new(typeof(ThemeConstants.Label), "OutlineSize", "outlineSize"); - [Obsolete("No longer differs between main and beta.")] - public static VariableReference - FontColor = new(typeof(ThemeConstants.Label), "FontColor", "fontColor"); - [Obsolete("No longer differs between main and beta.")] - public static VariableReference - FontOutlineColor = new(typeof(ThemeConstants.Label), "FontOutlineColor", "fontOutlineColor"); - [Obsolete("No longer differs between main and beta.")] - public static VariableReference - FontShadowColor = new(typeof(ThemeConstants.Label), "FontShadowColor", "fontShadowColor"); + return _fromCard.Invoke(command, card, cardPlay)!; } + private static VariableMethod _fromCard = new( + (typeof(AttackCommand), "FromCard", + [typeof(CardModel), typeof(CardPlay)], + [0, 1]), + (typeof(AttackCommand), "FromCard", + [typeof(CardModel)], + [0]) + ); + public static class AttackCommand_ { + [Obsolete("No longer differs between main and beta.")] public static VariableMethod TargetingAllOpponents = new((typeof(AttackCommand), "TargetingAllOpponents", [null], [0]) ); + [Obsolete("No longer differs between main and beta.")] public static VariableMethod TargetingRandomOpponents = new((typeof(AttackCommand), "TargetingRandomOpponents", [null, typeof(bool)], [0, 1]) @@ -67,6 +58,7 @@ public static class AttackCommand_ public static class Hook_ { + [Obsolete("No longer differs between main and beta.")] public static VariableMethod ModifyBlock = new((typeof(Hook), "ModifyBlock", [null, typeof(Creature), typeof(decimal), typeof(ValueProp), typeof(CardModel), typeof(CardPlay), typeof(IEnumerable)], [0, 1, 2, 3, 4, 5, 6]) @@ -75,6 +67,7 @@ public static class Hook_ public static class Creature_ { + [Obsolete("No longer differs between main and beta.")] public static CombatStateWrapper? WrappedCombatState(Creature creature) { var state = CombatState.Get(creature); @@ -84,6 +77,7 @@ public static class Creature_ private static MethodInfo? OldInfiniteHp = typeof(Creature).PropertyGetter("ShowsInfiniteHp"); private static MethodInfo? NewInfiniteHp = typeof(Creature).PropertyGetter(nameof(Creature.HpDisplay)); + [Obsolete("No longer differs between main and beta.")] public static bool ShowsInfiniteHp(Creature creature) { if (OldInfiniteHp != null) return (bool) (OldInfiniteHp.Invoke(creature, []) ?? throw new InvalidOperationException()); @@ -96,22 +90,26 @@ public static bool ShowsInfiniteHp(Creature creature) throw new InvalidOperationException("Could not find property for infinite hp check"); } + [Obsolete("No longer differs between main and beta.")] public static VariableReference CombatState = new(typeof(Creature), "CombatState"); } public static class CardModel_ { + [Obsolete("No longer differs between main and beta.")] public static CombatStateWrapper? WrappedCombatState(CardModel card) { var state = CombatState.Get(card); if (state == null) return null; return new CombatStateWrapper(state); } + [Obsolete("No longer differs between main and beta.")] public static VariableReference CombatState = new(typeof(CardModel), "CombatState"); } public static class PowerCmd_ { + [Obsolete("No longer differs between main and beta.")] public static VariableMethod Apply = new( (typeof(PowerCmd), "Apply", [typeof(PlayerChoiceContext), typeof(Creature), typeof(decimal), typeof(Creature), typeof(CardModel), typeof(bool)], @@ -119,6 +117,7 @@ public static class PowerCmd_ (typeof(PowerCmd), "Apply", [typeof(Creature), typeof(decimal), typeof(Creature), typeof(CardModel), typeof(bool)], [1, 2, 3, 4, 5])); + [Obsolete("No longer differs between main and beta.")] public static VariableMethod ApplyMulti = new( (typeof(PowerCmd), "Apply", [typeof(PlayerChoiceContext), typeof(IEnumerable), typeof(decimal), typeof(Creature), typeof(CardModel), typeof(bool)], @@ -130,6 +129,7 @@ public static class PowerCmd_ public static class RunState { + [Obsolete("No longer differs between main and beta.")] public static VariableMethod IterateHookListeners = new( (typeof(IRunState), "IterateHookListeners", [null], @@ -139,6 +139,7 @@ public static class RunState public static class _HoverTipFactory { + [Obsolete("No longer differs between main and beta.")] private static VariableMethod FromPowerDef = new( (typeof(HoverTipFactory), "FromPower", [typeof(int?)], @@ -150,6 +151,7 @@ public static class _HoverTipFactory m => m.IsGenericMethod) ); + [Obsolete("No longer differs between main and beta.")] private static VariableMethod FromPowerInstanceDef = new( (typeof(HoverTipFactory), "FromPower", [typeof(PowerModel), typeof(int?)], @@ -161,6 +163,7 @@ public static class _HoverTipFactory m => !m.IsGenericMethod) ); + [Obsolete("No longer differs between main and beta.")] public static IHoverTip FromPower() where T : PowerModel { if (FromPowerDef.ParamCount == 1) @@ -173,6 +176,7 @@ public static IHoverTip FromPower() where T : PowerModel } } + [Obsolete("No longer differs between main and beta.")] public static IHoverTip FromPower(PowerModel power, int? amount = null) { return FromPowerInstanceDef.Invoke(null, [power, amount])!; @@ -183,6 +187,7 @@ public static class _ModManifest { private static readonly FieldInfo DependencyField = typeof(ModManifest).DeclaredField("dependencies"); + [Obsolete("No longer differs between main and beta.")] public static bool HasDependency(ModManifest modManifest, string dependencyId) { var dependencies = DependencyField.GetValue(modManifest); @@ -380,6 +385,7 @@ public void InvokeGeneric(object? instance, params object?[] args) } } +[Obsolete("No longer differs between main and beta.")] public class CombatStateWrapper(object combatState) { static CombatStateWrapper() diff --git a/Utils/CommonActions.cs b/Utils/CommonActions.cs index caca09a3..faa9ea2d 100644 --- a/Utils/CommonActions.cs +++ b/Utils/CommonActions.cs @@ -19,20 +19,6 @@ namespace BaseLib.Utils; ///
public static class CommonActions { - /// - /// Performs an attack using a card's DamageVar or CalculatedDamageVar on the card play's target. - /// - /// - /// - /// - /// - /// - /// - /// - public static AttackCommand CardAttack(CardModel card, CardPlay play, int hitCount = 1, string? vfx = null, string? sfx = null, string? tmpSfx = null) - { - return CardAttack(card, play.Target, hitCount, vfx, sfx, tmpSfx); - } /// /// Performs an attack using a card's DamageVar or CalculatedDamageVar on a specified target. /// @@ -44,6 +30,7 @@ public static AttackCommand CardAttack(CardModel card, CardPlay play, int hitCou /// /// /// + [Obsolete("Use an overload that receives a CardPlay parameter. This is required on the beta branch.")] public static AttackCommand CardAttack(CardModel card, Creature? target, int hitCount = 1, string? vfx = null, string? sfx = null, string? tmpSfx = null) { if (card.DynamicVars.ContainsKey(CalculatedDamageVar.defaultName)) @@ -57,19 +44,29 @@ public static AttackCommand CardAttack(CardModel card, Creature? target, int hit } throw new Exception($"Card {card.Title} does not have a damage variable supported by CommonActions.CardAttack"); } + + /// + /// Performs an attack using a card's DamageVar or CalculatedDamageVar on the card play's target. + /// + public static AttackCommand CardAttack(CardModel card, CardPlay? play, int hitCount = 1, string? vfx = null, string? sfx = null, string? tmpSfx = null) + { + if (card.DynamicVars.ContainsKey(CalculatedDamageVar.defaultName)) + { + return CardAttack(card, play, play?.Target, card.DynamicVars.CalculatedDamage, card.DynamicVars.CalculatedDamage.Props, hitCount, vfx, sfx, tmpSfx); + } + + if (card.DynamicVars.ContainsKey(DamageVar.defaultName)) + { + return CardAttack(card, play, play?.Target, card.DynamicVars.Damage.BaseValue, card.DynamicVars.Damage.Props, hitCount, vfx, sfx, tmpSfx); + } + throw new Exception($"Card {card.Title} does not have a damage variable supported by CommonActions.CardAttack"); + } /// /// Performs an attacking using a specified amount of damage on a target. /// - /// - /// - /// - /// - /// - /// - /// - /// - /// + [Obsolete("Use the variant that has a CardPlay as the second parameter instead. This will be required for the beta branch." + + "If no CardPlay is available, use null.")] public static AttackCommand CardAttack(CardModel card, Creature? target, decimal damage, int hitCount = 1, string? vfx = null, string? sfx = null, string? tmpSfx = null) { return CardAttack(card, target, damage, ValueProp.Move, hitCount, vfx, sfx, tmpSfx); @@ -77,21 +74,23 @@ public static AttackCommand CardAttack(CardModel card, Creature? target, decimal /// /// Performs an attacking using a specified amount of damage on a target. + /// Note random targeting will default to allowing the same target multiple times. /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - public static AttackCommand CardAttack(CardModel card, Creature? target, decimal damage, ValueProp valueProp, int hitCount = 1, string? vfx = null, string? sfx = null, string? tmpSfx = null) + [Obsolete("Use the variant that has a CardPlay as the second parameter instead. This will be required for the beta branch." + + "If no CardPlay is available, use null.")] + public static AttackCommand CardAttack(CardModel card, Creature? target, decimal damage, ValueProp valueProp, + int hitCount = 1, string? vfx = null, string? sfx = null, string? tmpSfx = null) { - AttackCommand cmd = DamageCmd.Attack(damage).WithHitCount(hitCount).FromCard(card).WithValueProp(valueProp); + return CardAttack(card, null, target, damage, valueProp, hitCount, vfx, sfx, tmpSfx); + } + /// + /// Performs an attacking using a specified amount of damage on a target. + /// Note random targeting will default to allowing the same target multiple times. + /// + public static AttackCommand CardAttack(CardModel card, CardPlay? cardPlay, Creature? target, decimal damage, ValueProp valueProp, int hitCount = 1, string? vfx = null, string? sfx = null, string? tmpSfx = null) + { + AttackCommand cmd = DamageCmd.Attack(damage).WithHitCount(hitCount).WithValueProp(valueProp).FromCardCompatibility(card, cardPlay); if (CustomTargetType.IsCustomSingleTargetType(card.TargetType)) { @@ -100,7 +99,7 @@ public static AttackCommand CardAttack(CardModel card, Creature? target, decimal } else if (CustomTargetType.IsCustomMultiTargetType(card.TargetType)) { - var state = BetaMainCompatibility.CardModel_.WrappedCombatState(card); + var state = card.CombatState; if (state == null) return cmd; var targets = state.Creatures.Where(c => CustomTargetType.CanMultiTarget(card.TargetType, c, card.Owner)); cmd.TargetingFiltered(targets); @@ -114,14 +113,14 @@ public static AttackCommand CardAttack(CardModel card, Creature? target, decimal cmd.Targeting(target); break; case TargetType.AllEnemies: - var combatStateA = BetaMainCompatibility.CardModel_.CombatState.Get(card); + var combatStateA = card.CombatState; if (combatStateA == null) return cmd; - BetaMainCompatibility.AttackCommand_.TargetingAllOpponents.Invoke(cmd, combatStateA); + cmd.TargetingAllOpponents(combatStateA); break; case TargetType.RandomEnemy: - var combatStateB = BetaMainCompatibility.CardModel_.CombatState.Get(card); + var combatStateB = card.CombatState; if (combatStateB == null) return cmd; - BetaMainCompatibility.AttackCommand_.TargetingRandomOpponents.Invoke(cmd, combatStateB, true); + cmd.TargetingAllOpponents(combatStateB); break; default: throw new Exception($"Unsupported AttackCommand target type {card.TargetType} for card {card.Title}"); @@ -142,21 +141,25 @@ public static AttackCommand CardAttack(CardModel card, Creature? target, Calcula } /// - /// Performs an attacking using aCalculatedDamageVar on a target. + /// Performs an attack using a CalculatedDamageVar on a target. /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - public static AttackCommand CardAttack(CardModel card, Creature? target, CalculatedDamageVar calculatedDamage, ValueProp valueProp, int hitCount = 1, string? vfx = null, string? sfx = null, string? tmpSfx = null) + [Obsolete("Use the variant that has a CardPlay as the second parameter instead. This will be required for the beta branch." + + "If no CardPlay is available, use null.")] + public static AttackCommand CardAttack(CardModel card, Creature? target, CalculatedDamageVar calculatedDamage, + ValueProp valueProp, int hitCount = 1, string? vfx = null, string? sfx = null, string? tmpSfx = null) { - AttackCommand cmd = DamageCmd.Attack(calculatedDamage).WithHitCount(hitCount).FromCard(card).WithValueProp(valueProp); + return CardAttack(card, null, target, calculatedDamage, valueProp, hitCount, vfx, sfx, tmpSfx); + } + + /// + /// Performs an attack using a CalculatedDamageVar on a target. + /// Note random targeting will default to allowing the same target multiple times. + /// + public static AttackCommand CardAttack(CardModel card, CardPlay? cardPlay, Creature? target, + CalculatedDamageVar calculatedDamage, ValueProp valueProp, int hitCount = 1, + string? vfx = null, string? sfx = null, string? tmpSfx = null) + { + AttackCommand cmd = DamageCmd.Attack(calculatedDamage).WithHitCount(hitCount).WithValueProp(valueProp).FromCardCompatibility(card, cardPlay); if (CustomTargetType.IsCustomSingleTargetType(card.TargetType)) { @@ -165,7 +168,7 @@ public static AttackCommand CardAttack(CardModel card, Creature? target, Calcula } else if (CustomTargetType.IsCustomMultiTargetType(card.TargetType)) { - var state = BetaMainCompatibility.CardModel_.WrappedCombatState(card); + var state = card.CombatState; if (state == null) return cmd; var targets = state.Creatures.Where(c => CustomTargetType.CanMultiTarget(card.TargetType, c, card.Owner)); cmd.TargetingFiltered(targets); @@ -179,14 +182,14 @@ public static AttackCommand CardAttack(CardModel card, Creature? target, Calcula cmd.Targeting(target); break; case TargetType.AllEnemies: - var combatStateA = BetaMainCompatibility.CardModel_.CombatState.Get(card); + var combatStateA = card.CombatState; if (combatStateA == null) return cmd; - BetaMainCompatibility.AttackCommand_.TargetingAllOpponents.Invoke(cmd, combatStateA); + cmd.TargetingAllOpponents(combatStateA); break; case TargetType.RandomEnemy: - var combatStateB = BetaMainCompatibility.CardModel_.CombatState.Get(card); + var combatStateB = card.CombatState; if (combatStateB == null) return cmd; - BetaMainCompatibility.AttackCommand_.TargetingRandomOpponents.Invoke(cmd, combatStateB, true); + cmd.TargetingRandomOpponents(combatStateB); break; default: throw new Exception( @@ -231,16 +234,11 @@ public static async Task CardBlock(CardModel card, BlockVar blockVar, C /// /// Gains Block based on the given DynamicVar (supports CalculatedBlockVar) /// - /// - /// - /// - /// - /// public static async Task CardBlock(CardModel card, DynamicVar var, CardPlay? play, bool fast = false) { if (var is CalculatedBlockVar calculated) { - return await CreatureCmd.GainBlock(card.Owner.Creature, calculated.Calculate(play?.Target), calculated.Props, play, fast); + return await CreatureCmd.GainBlock(card.Owner.Creature, calculated.Calculate(card.Owner.Creature), calculated.Props, play, fast); } return await CreatureCmd.GainBlock(card.Owner.Creature, var.BaseValue, (var as BlockVar)?.Props ?? ValueProp.Move, play, fast); } diff --git a/Utils/DynamicVarSource.cs b/Utils/DynamicVarSource.cs index d7b62bcf..ff0b1cdb 100644 --- a/Utils/DynamicVarSource.cs +++ b/Utils/DynamicVarSource.cs @@ -11,7 +11,8 @@ namespace BaseLib.Utils; public sealed class DynamicVarSource() { public required DynamicVarSet DynamicVars { get; init; } - public required Creature Owner { get; init; } + public required Creature? Owner { get; init; } + //Used as cardsource when passing a DynamicVarSource to common actions public CardModel? Card { get; init; } //Unused @@ -26,7 +27,7 @@ public static implicit operator DynamicVarSource(CardModel card) return new DynamicVarSource { DynamicVars = card.DynamicVars, - Owner = card.Owner.Creature, + Owner = card is { IsMutable: true, Owner: not null } ? card.Owner.Creature : null, Card = card }; } @@ -36,7 +37,7 @@ public static implicit operator DynamicVarSource(RelicModel relic) return new DynamicVarSource { DynamicVars = relic.DynamicVars, - Owner = relic.Owner.Creature, + Owner = relic is { IsMutable: true, Owner: not null } ? relic.Owner.Creature : null, Relic = relic }; } @@ -56,7 +57,7 @@ public static implicit operator DynamicVarSource(PotionModel potion) return new DynamicVarSource { DynamicVars = potion.DynamicVars, - Owner = potion.Owner.Creature + Owner = potion is { IsMutable: true, Owner: not null } ? potion.Owner.Creature : null }; } @@ -65,7 +66,7 @@ public static implicit operator DynamicVarSource(EnchantmentModel enchant) return new DynamicVarSource { DynamicVars = enchant.DynamicVars, - Owner = enchant.Card.Owner.Creature, + Owner = enchant.Card.IsMutable ? enchant.Card.Owner.Creature : null, Card = enchant.Card }; } @@ -75,7 +76,7 @@ public static implicit operator DynamicVarSource(CardModifier modifier) return new DynamicVarSource { DynamicVars = modifier.DynamicVars, - Owner = modifier.Owner!.Owner.Creature, + Owner = modifier.Owner?.IsMutable == true ? modifier.Owner?.Owner.Creature : null, Card = modifier.Owner }; } diff --git a/Utils/Patching/InstructionMatcher.cs b/Utils/Patching/InstructionMatcher.cs index 646e5ab0..fb7ad615 100644 --- a/Utils/Patching/InstructionMatcher.cs +++ b/Utils/Patching/InstructionMatcher.cs @@ -46,13 +46,22 @@ public bool Match(List log, List code, int startIndex, continue; } - log.Add($"Opcode match but operand mismatch {code[i].opcode} | [{code[i].operand?.GetType() ?? null}]{code[i].operand} vs {_target[matchIndex].Operand}"); + log.Add($"Opcode match but operand mismatch {matchTest.opcode} | [{matchTest.operand?.GetType() ?? null}]{matchTest.operand} vs {matchTarget.Operand}"); } - if (matchIndex <= 0) continue; + //Match failed - log.Add($"Match ended, opcodes do not match ({code[i].opcode}, {_target[matchIndex].Opcodes})"); - matchIndex = 0; + if (matchIndex <= 0) continue; + + if (matchTarget.IsLazy) + { + log.Add("Ignoring mismatch; current match is lazy"); + } + else + { + log.Add($"Match ended, opcodes do not match ({matchTest.opcode}, {matchTarget.Opcodes})"); + matchIndex = 0; + } } return false; } @@ -65,10 +74,12 @@ public override string ToString() private class InstructionMatch { + public OpCode[] Opcodes { get; } public Func? OperandFunc { get; set; } = null; public Predicate? OperandMatchPredicate { get; set; } = null; public string? StoreOperandKey { get; set; } = null; - private readonly object? _operand; + + public bool IsLazy { get; set; } = false; public InstructionMatch(OpCode opcode, object? operand = null) { @@ -76,18 +87,16 @@ public InstructionMatch(OpCode opcode, object? operand = null) Operand = operand; } - public InstructionMatch(OpCode[] opcodes) + public InstructionMatch(OpCode[] opcodes, object? operand = null) { Opcodes = opcodes; - Operand = null; + Operand = operand; } - public OpCode[] Opcodes { get; } - public object? Operand { - get => OperandFunc?.Invoke() ?? _operand; - private init => _operand = value; + get => OperandFunc?.Invoke() ?? field; + private init; } public bool OperandMatch(CodeInstruction matchTest) @@ -139,6 +148,17 @@ public InstructionMatcher PredicateMatch(Predicate operandCondition) return this; } + /// + /// Causes previous instruction to be matched lazily, ignoring any non-matching instructions until a match is found. + /// + /// + public InstructionMatcher LazyMatch() + { + if (_target.Count == 0) + throw new InvalidOperationException("Cannot use predicate for operand without adding any instructions"); + _target[^1].IsLazy = true; + return this; + } //Building //https://learn.microsoft.com/en-us/dotnet/api/system.reflection.emit.opcodes.add?view=net-10.0 @@ -184,6 +204,18 @@ public InstructionMatcher call_any() ])); return this; } + public InstructionMatcher call_any(Type declaringType, string methodName, Type[]? parameters = null, Type[]? generics = null) + { + return call_any(AccessTools.Method(declaringType, methodName, parameters, generics)); + } + public InstructionMatcher call_any(MethodInfo? method) + { + _target.Add(new InstructionMatch([ + OpCodes.Call, + OpCodes.Callvirt + ], method)); + return this; + } //Normal opcodes public InstructionMatcher opcode(OpCode opCode) @@ -201,6 +233,65 @@ public InstructionMatcher Break() _target.Add(new(OpCodes.Break)); return this; } + /// + /// Matches a non-address ldarg for the specified argument index. + /// + public InstructionMatcher ldargIndex(int index) + { + switch (index) + { + case 0: + _target.Add(new InstructionMatch([ + OpCodes.Ldarg_0, + OpCodes.Ldarg, + OpCodes.Ldarg_S + ]) + { + OperandMatchPredicate = op => (op == null) || (op is IConvertible convertible && convertible.ToInt32(null) == index) + }); + break; + case 1: + _target.Add(new InstructionMatch([ + OpCodes.Ldarg_1, + OpCodes.Ldarg, + OpCodes.Ldarg_S + ]) + { + OperandMatchPredicate = op => (op == null) || (op is IConvertible convertible && convertible.ToInt32(null) == index) + }); + break; + case 2: + _target.Add(new InstructionMatch([ + OpCodes.Ldarg_2, + OpCodes.Ldarg, + OpCodes.Ldarg_S + ]) + { + OperandMatchPredicate = op => (op == null) || (op is IConvertible convertible && convertible.ToInt32(null) == index) + }); + break; + case 3: + _target.Add(new InstructionMatch([ + OpCodes.Ldarg_3, + OpCodes.Ldarg, + OpCodes.Ldarg_S + ]) + { + OperandMatchPredicate = op => (op == null) || (op is IConvertible convertible && convertible.ToInt32(null) == index) + }); + break; + default: + _target.Add(new InstructionMatch([ + OpCodes.Ldarg, + OpCodes.Ldarg_S + ]) + { + OperandMatchPredicate = op => (op is IConvertible convertible && convertible.ToInt32(null) == index) + }); + break; + } + return this; + } public InstructionMatcher ldarg_0() { _target.Add(new(OpCodes.Ldarg_0)); diff --git a/Utils/Patching/InstructionPatcher.cs b/Utils/Patching/InstructionPatcher.cs index 7c0daeb3..5c723cbe 100644 --- a/Utils/Patching/InstructionPatcher.cs +++ b/Utils/Patching/InstructionPatcher.cs @@ -29,12 +29,11 @@ public InstructionPatcher ResetPosition() /// After matching is complete, position is on the code instruction following the last match. /// If a match is not found, an exception will be thrown. ///
- /// - /// public InstructionPatcher Match(params IMatcher[] matchers) { return Match(DefaultMatchFailure, matchers); } + /// /// Iterates over given matchers and attempts to match each in order. /// After matching is complete, position is on the code instruction following the last match. @@ -77,6 +76,54 @@ public InstructionPatcher Match(Action onFailMatch, params IMatcher[ Log.Add("Found end of match at " + _index + "; last match starts at " + _lastMatchStart); return this; } + /// + /// Iterates over given matchers and attempts to match each in order, starting from the end of the method. + /// After matching is complete, position is on the code instruction following the last match. + /// If a match is not found, an exception will be thrown. + /// + public InstructionPatcher MatchFromEnd(params IMatcher[] matchers) + { + return MatchFromEnd(DefaultMatchFailure, matchers); + } + + /// + /// Iterates over given matchers and attempts to match each in order, starting from the end of the method. + /// After matching is complete, position is on the code instruction following the last match. + /// If a match is not found, onFailMatch is called. By default, this will throw an exception. + /// + public InstructionPatcher MatchFromEnd(Action onFailMatch, params IMatcher[] matchers) + { + int searchIndex = _code.Count; + while (searchIndex > 0) + { + _index = --searchIndex; + var matched = true; + + foreach (IMatcher matcher in matchers) + { + if (!matcher.Match(Log, _code, _index, out _lastMatchStart, out _index)) + { + matched = false; + break; + } + } + + if (matched) + { + break; + } + } + + if (searchIndex == 0) + { + onFailMatch(matchers); + return this; + } + + Log.Add("Found end of match at " + _index + "; last match starts at " + _lastMatchStart); + + return this; + } public InstructionPatcher MatchStart() { diff --git a/Utils/SpireField.cs b/Utils/SpireField.cs index 4dce6d5f..e05a9393 100644 --- a/Utils/SpireField.cs +++ b/Utils/SpireField.cs @@ -9,13 +9,47 @@ namespace BaseLib.Utils; +/// +/// Marks a SpireField whose value will be copied if it has been set. +/// SpireFields utilizing this interface add themselves to +/// if their Clone method should be called. +/// +public interface ICloneableField +{ + private static NotNullSpireField> CloneFields = new(() => []); + + /// + /// Adds an ICloneableField to the set of fields whose Clone method will be called when the model is cloned. + /// + public static void AddClonedField(AbstractModel model, ICloneableField field) + { + CloneFields[model].Add(field); + } + + /// + /// Copies this field's data from a source model to a destination model when the model is cloned. + /// + public void Clone(AbstractModel src, AbstractModel dst); + + [HarmonyPatch(typeof(AbstractModel), nameof(AbstractModel.MutableClone))] + private static class CloneSpireFields { + [HarmonyPostfix] + static void ModifyResult(AbstractModel __instance, AbstractModel __result) + { + var cloneFields = CloneFields[__instance]; + foreach (var cloneableField in cloneFields) + { + cloneableField.Clone(__instance, __result); + } + } + } +} + /// /// A basic wrapper around for convenience. /// While this can be used to store value types, they will be boxed and thus is somewhat inefficient. /// -/// -/// -public class SpireField where TKey : class +public class SpireField : ICloneableField where TKey : class { private readonly ConditionalWeakTable _table = []; private readonly Func _defaultVal; @@ -30,6 +64,47 @@ public SpireField(Func defaultVal) _defaultVal = defaultVal; } + /// + /// Causes this SpireField's value to be copied when the model it is attached to is cloned. Only valid for + /// SpireFields attached to types inheriting from AbstractModel. + /// The value will only be copied over if it has been set at least once outside of the default value, or if the + /// default value is a reference type and its value has been retrieved at least once. + /// Note that this is a shallow clone; reference types will be assigned directly to the new instance, not copied. + /// Optional cloneVal parameter will change how the value is copied to the new instance. + /// + /// A function to copy the value to the new model. Receives the source model, + /// destination model, and the field's value for the source model. Should call Set on this SpireField with the + /// destination model or otherwise copy values over. + public SpireField CopyOnClone(Action? cloneVal = null) + { + if (!typeof(TKey).IsAssignableTo(typeof(AbstractModel))) + { + throw new InvalidOperationException( + $"Cannot enable CopyOnClone for SpireField on type {typeof(TKey).Name}; only valid for SpireFields attached to AbstractModel types."); + } + _cloneFunc = cloneVal ?? ((_, dst, val) => Set(dst, val)); + return this; + } + + private Action? _cloneFunc; + /// + /// Returns true if this SpireField's value should be copied over when a model it is attached to is cloned. + /// + public bool ShouldClone => _cloneFunc != null; + + /// + /// Copies this SpireField's value from one AbstractModel to another. + /// Only usable if this SpireField is attached to a model type. + /// + public void Clone(AbstractModel src, AbstractModel dst) + { + if (src is not TKey srcKey || dst is not TKey dstKey) + throw new ArgumentException( + $"Unable to clone SpireField on type {typeof(TKey).Name} from {src.GetType().Name} to {dst.GetType().Name}."); + if (!ShouldClone) return; + _cloneFunc!(srcKey, dstKey, Get(srcKey)); + } + public TVal? this[TKey obj] { get => Get(obj); @@ -40,19 +115,27 @@ public TVal? this[TKey obj] if (_table.TryGetValue(obj, out var result)) return (TVal?)result; _table.Add(obj, result = _defaultVal(obj)); + if (ShouldClone && !typeof(TVal).IsValueType && obj is AbstractModel model) + { + ICloneableField.AddClonedField(model, this); + } return (TVal?)result; } public void Set(TKey obj, TVal? val) { _table.AddOrUpdate(obj, val); + if (ShouldClone && obj is AbstractModel model) + { + ICloneableField.AddClonedField(model, this); + } } } /// /// A SpireField containing an object whose value is guaranteed to not be null. /// -public class NotNullSpireField where TKey : class where TVal : class +public class NotNullSpireField : ICloneableField where TKey : class where TVal : class { private readonly ConditionalWeakTable _table = []; private readonly Func _defaultVal; @@ -66,6 +149,45 @@ public NotNullSpireField(Func defaultVal) { _defaultVal = defaultVal; } + + /// + /// Causes this SpireField's value to be copied when the model it is attached to is cloned. Only valid for + /// SpireFields attached to types inheriting from AbstractModel. + /// The value will only be copied over if it has been set at least once outside of the default value, or if the + /// default value is a reference type. + /// Note that this is a shallow clone; reference types will be assigned directly to the new instance, not copied. + /// Optional cloneVal parameter will change how the value is copied to the new instance. + /// + public NotNullSpireField CopyOnClone(Action? cloneVal = null) + { + if (!typeof(TKey).IsAssignableTo(typeof(AbstractModel))) + { + throw new InvalidOperationException( + $"Cannot enable CopyOnClone for SpireField on type {typeof(TKey).Name}; only valid for SpireFields attached to AbstractModel types."); + } + _cloneFunc = cloneVal ?? ((_, dst, val) => Set(dst, val)); + return this; + } + + private Action? _cloneFunc; + /// + /// Returns true if this SpireField's value should be copied over when a model it is attached to is cloned. + /// + public bool ShouldClone => _cloneFunc != null; + + /// + /// Copies this SpireField's value from one AbstractModel to another. + /// Only usable if this SpireField is attached to a model type. + /// + public void Clone(AbstractModel src, AbstractModel dst) + { + if (src is not TKey srcKey || dst is not TKey dstKey) + throw new ArgumentException( + $"Unable to clone SpireField on type {typeof(TKey).Name} from {src.GetType().Name} to {dst.GetType().Name}."); + if (!ShouldClone) return; + + _cloneFunc!(srcKey, dstKey, Get(srcKey)); + } public TVal this[TKey obj] { @@ -77,13 +199,22 @@ public TVal Get(TKey obj) { if (_table.TryGetValue(obj, out var result)) return result; var defaultVal = _defaultVal(obj); + _table.Add(obj, defaultVal); + if (ShouldClone && !typeof(TVal).IsValueType && obj is AbstractModel model) + { + ICloneableField.AddClonedField(model, this); + } return defaultVal; } public void Set(TKey obj, TVal val) { _table.AddOrUpdate(obj, val); + if (ShouldClone && obj is AbstractModel model) + { + ICloneableField.AddClonedField(model, this); + } } } @@ -214,8 +345,6 @@ internal interface ISavedSpireField /// A SpireField whose value will automatically be saved and loaded. /// Only functions on model types that support SavedProperty, so mainly just cards and relics. /// -/// -/// public class SavedSpireField : SpireField, ISavedSpireField where TKey : class { public SavedSpireField(Func defaultVal, string name) : this(_ => defaultVal(), name) { } diff --git a/Utils/TooltipSource.cs b/Utils/TooltipSource.cs index 23fdfc1b..a4473ce7 100644 --- a/Utils/TooltipSource.cs +++ b/Utils/TooltipSource.cs @@ -23,7 +23,7 @@ public static implicit operator TooltipSource(Type t) { if (t.IsAssignableTo(typeof(PowerModel))) { - return new((card)=>BetaMainCompatibility._HoverTipFactory.FromPower(ModelDb.GetById(ModelDb.GetId(t)))); + return new((card)=>HoverTipFactory.FromPower(ModelDb.GetById(ModelDb.GetId(t)))); } if (t.IsAssignableTo(typeof(CardModel))) { From bdbeea0152a4fe1e0d6383bedd1ddfe9efe73cf8 Mon Sep 17 00:00:00 2001 From: quipsol <245758987+quipsol@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:08:00 +0200 Subject: [PATCH 3/6] Support custom Eras --- Abstracts/CustomCharacterModel.cs | 30 +- Abstracts/CustomEpochEra.cs | 57 +++- Abstracts/CustomEpochModel.cs | 41 +-- Patches/Content/ContentPatches.cs | 13 +- Patches/Content/CustomEpochEraPatches.cs | 341 +++++++++++++++++++++++ Patches/Content/CustomEpochPatches.cs | 6 - 6 files changed, 439 insertions(+), 49 deletions(-) create mode 100644 Patches/Content/CustomEpochEraPatches.cs delete mode 100644 Patches/Content/CustomEpochPatches.cs diff --git a/Abstracts/CustomCharacterModel.cs b/Abstracts/CustomCharacterModel.cs index 8e9a5a56..98ff7095 100644 --- a/Abstracts/CustomCharacterModel.cs +++ b/Abstracts/CustomCharacterModel.cs @@ -20,6 +20,8 @@ using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; using MegaCrit.Sts2.Core.Nodes.Screens.Shops; using MegaCrit.Sts2.Core.Saves; +using MegaCrit.Sts2.Core.Saves.Managers; +using MegaCrit.Sts2.Core.Saves.Runs; using MegaCrit.Sts2.Core.Timeline; using MegaCrit.Sts2.Core.Timeline.Epochs; @@ -291,8 +293,9 @@ private static void QueueUnlocksInsert() // Does not support unlocking after custom characters + // in CustomCharacterModel because it needs to access "UnlocksAfterRunAs" [HarmonyPatch] - internal static class TimelineExpansionPatch + private static class TimelineExpansionPatch { private static IEnumerable TargetMethods() { @@ -336,6 +339,31 @@ c.UnlockEpoch is not null && ]; } } + + [HarmonyPatch] + private static class PostRunUnlock + { + [HarmonyPatch(typeof(ProgressSaveManager), "PostRunUnlockCharacterEpochCheck")] + [HarmonyPrefix] + private static void UnlockCustomCharacterEpoch(ProgressSaveManager __instance, SerializablePlayer serializablePlayer, SerializableRun serializableRun) + { + var customCharacters = CustomContentDictionary.CustomCharacters + .Where(c => c.UnlockEpoch is not null) + .Where(c => c.UnlocksAfterRunAs is not null or Ironclad); + foreach (var customCharacter in customCharacters) + { + if (customCharacter.UnlocksAfterRunAs!.Id != serializablePlayer.CharacterId) continue; + var res = CustomEpochModel.CustomEpochPatches.TryObtainEpochPostRunMethod?.Invoke(__instance, + [customCharacter.UnlockEpoch, serializablePlayer, serializableRun]); + if(res is true) + BaseLibMain.Logger.Info($"Epoch {customCharacter.UnlockEpoch!.Id} obtained for playing a run as {serializablePlayer.CharacterId}."); + } + + + + } + + } } diff --git a/Abstracts/CustomEpochEra.cs b/Abstracts/CustomEpochEra.cs index 3b05bb7b..52df25bb 100644 --- a/Abstracts/CustomEpochEra.cs +++ b/Abstracts/CustomEpochEra.cs @@ -1,12 +1,22 @@ -using Godot; +using System.Reflection; +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.addons.mega_text; using MegaCrit.Sts2.Core.Assets; using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Localization; +using MegaCrit.Sts2.Core.Nodes.Screens.Timeline; using MegaCrit.Sts2.Core.Timeline; namespace BaseLib.Abstracts; + + public abstract class CustomEpochEra { + /// + /// Points to a new [CustomEnum] EpochEra. + /// public abstract EpochEra CustomEra { get; } /// @@ -36,6 +46,47 @@ public abstract class CustomEpochEra /// Stops the game from applying the default tint to the era icon. /// public virtual bool DisableTinting => false; + + + [HarmonyPatch] + private static class Patches + { + private static readonly FieldInfo? NEraColumnIconField = AccessTools.Field(typeof(NEraColumn), "_icon"); + private static readonly FieldInfo? NEraColumnNameField = AccessTools.Field(typeof(NEraColumn), "_name"); + private static readonly FieldInfo? NEraColumnYearField = AccessTools.Field(typeof(NEraColumn), "_year"); + [HarmonyPostfix] + [HarmonyPatch(typeof(NEraColumn), nameof(NEraColumn.Init))] + private static void ReplaceIconForCustomEraColumn(NEraColumn __instance, EpochSlotData epochSlot) + { + BaseLibMain.Logger.Info("ReplaceIconForCustomEraColumn"); + // We assume in a custom Era can only be CustomEpochs + if (epochSlot.Model is not CustomEpochModel customEpochModel) return; + if (customEpochModel.CustomEra is null) return; + if (NEraColumnIconField?.GetValue(__instance) is not TextureRect textureRect) return; + if (NEraColumnNameField?.GetValue(__instance) is not MegaLabel name) return; + if (NEraColumnYearField?.GetValue(__instance) is not MegaLabel year) return; + + if (textureRect.Texture is null) + textureRect.Visible = true; // set to false at some point if the original method cant get a proper texture + textureRect.Texture = customEpochModel.CustomEra.EraIconTexture; + BaseLibMain.Logger.Info($"{textureRect.Texture.ResourcePath}"); + if (customEpochModel.CustomEra.UseOriginalImageSize) + { + var originalSize = customEpochModel.CustomEra.EraIconTexture.GetSize(); + textureRect.Size = originalSize; + textureRect.Position -= (originalSize / 2) - new Vector2(24, 24); + // large textures would begin overlapping the bottom epochs + textureRect.MouseFilter = Control.MouseFilterEnum.Ignore; + } + if (customEpochModel.CustomEra.DisableTinting) + { + textureRect.Modulate = new Color("ffffff"); + } + var slugifiedName = StringHelper.Slugify(customEpochModel.CustomEra.GetType().Name); + name.SetTextAutoSize(new LocString("eras", slugifiedName + ".name").GetFormattedText()); + year.SetTextAutoSize(new LocString("eras", slugifiedName + ".year").GetFormattedText()); + } + } } /// @@ -48,11 +99,11 @@ public enum RelativeEraDirection /// None, /// - /// Insert to the left. + /// Insert to the left of reference Era. /// Before, /// - /// Insert to the right. + /// Insert to the right of reference Era. /// After } \ No newline at end of file diff --git a/Abstracts/CustomEpochModel.cs b/Abstracts/CustomEpochModel.cs index 19a7c72d..cb5bbedc 100644 --- a/Abstracts/CustomEpochModel.cs +++ b/Abstracts/CustomEpochModel.cs @@ -66,7 +66,7 @@ private static bool CustomEpochRealPortraitPath(EpochModel __instance, ref strin return false; } - [HarmonyPatch(typeof(EpochModel), nameof(PackedPortraitPath), MethodType.Getter)] + [HarmonyPatch(typeof(EpochModel), "PackedPortraitPath", MethodType.Getter)] [HarmonyPrefix] private static bool CustomEpochPackedPortraitPath(EpochModel __instance, ref string? __result) { @@ -144,13 +144,6 @@ private static void EnforceUniqueEraPosition(NEraColumn nEraColumn, NEpochSlot n [HarmonyTranspiler] private static List ResolveSetStateForNewEraPositions(IEnumerable instructions) { - var insts = instructions.ToList(); - for (var i = 0; i < insts.Count; i++) - { - BaseLibMain.Logger.Info($"Instruction {i} => OpCode: {insts[i].opcode} | Operand: {insts[i].operand}"); - } - - //ldloc 12 9 var getActualEraPosition = typeof(DuplicateEraPositionHandler).Method(nameof(GetActualEraPosition)); var matcher = new CodeMatcher(instructions) .MatchStartForward([ @@ -171,14 +164,7 @@ private static List ResolveSetStateForNewEraPositions(IEnumerab new CodeInstruction(OpCodes.Ldloc, epochModel), new CodeInstruction(OpCodes.Call, getActualEraPosition), new CodeInstruction(OpCodes.Brfalse_S, target), - ]) - ; - - var list = matcher.InstructionEnumeration().ToList(); - for (var i = 0; i < list.Count; i++) - { - BaseLibMain.Logger.Info($"Instruction {i} => OpCode: {list[i].opcode} | Operand: {list[i].operand}"); - } + ]); return matcher.InstructionEnumeration().ToList(); } private static bool GetActualEraPosition(NEpochSlot nEpochSlot, EpochModel epochModel) @@ -188,24 +174,6 @@ private static bool GetActualEraPosition(NEpochSlot nEpochSlot, EpochModel epoch } - // If the patch above stops working we can also do this. - // However, the patch above runs BEFORE NEpochSlot._Ready() is called, where as this runs after - - // [HarmonyPatch(typeof(NEraColumn), nameof(NEraColumn.AddSlot))] - // [HarmonyPostfix] - // private static void AdjustNEpochSlotEraPosition(NEraColumn __instance) - // { - // var nEpochSlot = __instance.GetChildren().OfType().FirstOrDefault(); - // if (nEpochSlot is null) return; - // var allCurrentNEpochSlots = __instance.GetChildren().OfType().Except([nEpochSlot]).ToList(); - // var oldEraPosition = nEpochSlot.eraPosition; - // while (allCurrentNEpochSlots.Any(o => o.eraPosition == nEpochSlot.eraPosition)) - // nEpochSlot.eraPosition++; - // if (oldEraPosition == nEpochSlot.eraPosition) return; - // nEpochSlot.Name = $"Slot{nEpochSlot.eraPosition}"; // We dont need to move the child because the game always moves them to pos 0 - // BaseLibMain.Logger.Info($"Moved EpochSlot position for Epoch {nEpochSlot.model.Id} from {oldEraPosition} to {nEpochSlot.eraPosition}"); - // } - [HarmonyPatch(typeof(SaveManager), "GetCardUnlockEpochIds")] [HarmonyPostfix] private static void InsertCardUnlockCustomEpochs(ref string[] __result) @@ -226,7 +194,7 @@ private static void LockCharactersWithUnlockEpoch(UnlockState __instance, ref IE private static readonly MethodInfo? TryObtainEpochMidRunMethod = typeof(ProgressSaveManager) .GetMethod("TryObtainEpochMidRun", BindingFlags.Instance | BindingFlags.NonPublic); - private static readonly MethodInfo? TryObtainEpochPostRunMethod = typeof(ProgressSaveManager) + public static readonly MethodInfo? TryObtainEpochPostRunMethod = typeof(ProgressSaveManager) .GetMethod("TryObtainEpochPostRun", BindingFlags.Instance | BindingFlags.NonPublic); private static readonly MethodInfo? GetEliteEncountersMethod = typeof(ProgressSaveManager) .GetMethod("GetEliteEncounters", BindingFlags.Static | BindingFlags.NonPublic); @@ -346,7 +314,8 @@ private static bool SkipAscensionOneEpochIfUnsupported(ProgressSaveManager __ins return false; } - + + diff --git a/Patches/Content/ContentPatches.cs b/Patches/Content/ContentPatches.cs index 0918df17..0410047f 100644 --- a/Patches/Content/ContentPatches.cs +++ b/Patches/Content/ContentPatches.cs @@ -425,13 +425,20 @@ static IEnumerable AddCustomEvents(IEnumerable result, A } } -[HarmonyPatch(typeof(EpochModel), "EpochIds", MethodType.Getter)] +[HarmonyPatch] public class EpochModelCustomEpochsPatch { - + [HarmonyPatch(typeof(EpochModel), nameof(EpochModel.AllEpochIds), MethodType.Getter)] [HarmonyPostfix] - private static void InsertEpochIds(EpochModel __instance, ref IEnumerable __result) + private static void InsertEpochIds(EpochModel __instance, ref IReadOnlyList __result) { __result = [.. __result, .. CustomContentDictionary.CustomEpochs.Select(x => x.Id)]; } + + [HarmonyPatch(typeof(EpochModel), nameof(EpochModel.AllEpochs), MethodType.Getter)] + [HarmonyPostfix] + private static void InsertEpochTypes(EpochModel __instance, ref IReadOnlyList __result) + { + __result = [.. __result, .. CustomContentDictionary.CustomEpochs.Select(x => x.GetType())]; + } } \ No newline at end of file diff --git a/Patches/Content/CustomEpochEraPatches.cs b/Patches/Content/CustomEpochEraPatches.cs new file mode 100644 index 00000000..e7af65c1 --- /dev/null +++ b/Patches/Content/CustomEpochEraPatches.cs @@ -0,0 +1,341 @@ +using System.Reflection; +using System.Reflection.Emit; +using BaseLib.Abstracts; +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.addons.mega_text; +using MegaCrit.Sts2.Core.Extensions; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Localization; +using MegaCrit.Sts2.Core.Nodes.Screens.Timeline; +using MegaCrit.Sts2.Core.Timeline; + +namespace BaseLib.Patches.Content; + +/// +/// Massive patch to hand insertion of entirely new Eras +/// +[HarmonyPatch] +public class AddCustomEpochSlots +{ + private static List _customEpochSlotData = []; + private static Vector2 _originalEpochSlotsContainerPosition = new(0, 0); + + private static readonly FieldInfo? UniqueEpochErasField = AccessTools.Field(typeof(NTimelineScreen), "_uniqueEpochEras"); + private static readonly FieldInfo? EpochSlotContainerField = AccessTools.Field(typeof(NTimelineScreen), "_epochSlotContainer"); + private static readonly FieldInfo? SlotsContainerField = AccessTools.Field(typeof(NTimelineScreen), "_slotsContainer"); + + + [HarmonyPrefix] + [HarmonyPatch(typeof(NTimelineScreen), nameof(NTimelineScreen.AddEpochSlots))] + private static void RemoveSelectCustomEpochsFromDefaultLogic(NTimelineScreen __instance, ref List slotsToAdd) + { + _customEpochSlotData = []; + var allSlots = slotsToAdd; + slotsToAdd = allSlots.Where(s => s.Model is not CustomEpochModel model || model.CustomEra is null).ToList(); + _customEpochSlotData = allSlots.Where(s => s.Model is CustomEpochModel { CustomEra: not null }).ToList(); + //_customEpochSlotData = allSlots.Except(slotsToAdd).ToList(); + } + + [HarmonyTranspiler] + [HarmonyPatch(typeof(NTimelineScreen), nameof(NTimelineScreen.AddEpochSlots), MethodType.Async)] + private static List InsertCustomEraColumnsAndEpochs(IEnumerable instructions, MethodBase originalMethod) + { + var customEpochEraHandler = typeof(AddCustomEpochSlots).Method(nameof(CustomEpochEraHandler)); + var thisField = AccessTools.GetDeclaredFields(originalMethod.DeclaringType).First(f => f.FieldType == typeof(NTimelineScreen)); + + var matcher = new CodeMatcher(instructions) + .MatchStartForward([ + new CodeMatch(OpCodes.Ldloca_S), + new CodeMatch(OpCodes.Ldstr, " Created "), + ]) + .ThrowIfInvalid("Could not find position to insert custom epoch era Handler") + .Insert([ + new CodeInstruction(OpCodes.Ldarg_0), + new CodeInstruction(OpCodes.Ldfld, thisField), + new CodeInstruction(OpCodes.Ldloc_2), + new CodeInstruction(OpCodes.Call, customEpochEraHandler), + ]); + return matcher.InstructionEnumeration().ToList(); + } + + private static void CustomEpochEraHandler(NTimelineScreen instance, List newlyCreatedColumns) + { + BaseLibMain.Logger.Warn($"Called {nameof(CustomEpochEraHandler)}"); + BaseLibMain.Logger.Warn($"List count: {newlyCreatedColumns.Count}"); + + if (UniqueEpochErasField is null || EpochSlotContainerField is null) return; + var uniqueEpochErasObject = UniqueEpochErasField.GetValue(instance); + var epochSlotContainerObject = EpochSlotContainerField.GetValue(instance); + if (uniqueEpochErasObject == null || epochSlotContainerObject == null) return; + if (uniqueEpochErasObject is not Dictionary uniqueEpochEras) return; + if (epochSlotContainerObject is not HBoxContainer epochSlotContainer) return; + + BaseLibMain.Logger.Warn("Reflection successful"); + + var customEraColumnData = SortSlotsIntoCustomEraColumns(); + + var originalNEraColumns = epochSlotContainer.GetChildren().OfType().ToList(); + var ourCustomColumns = new Dictionary(); + + // For every column we have + foreach (var columnData in customEraColumnData) + { + var customEpochEra = columnData.CustomEpochEra.CustomEra; + // for every slot inside that column + for (var epochSlotDataIndex = 0; epochSlotDataIndex < columnData.EpochSlotData.Count; epochSlotDataIndex++) + { + var customEpochModel = columnData.CustomEpochs[epochSlotDataIndex]; // data and model count should always be equal + if (customEpochModel.CustomEra is null) continue; // Should never be true + + if (uniqueEpochEras.TryGetValue(customEpochEra, out var nEraColumn)) + { + nEraColumn.AddSlot(columnData.EpochSlotData[epochSlotDataIndex]); + } + else + { + var newNEraColumn = NEraColumn.Create(columnData.EpochSlotData[epochSlotDataIndex]); + ourCustomColumns.Add(columnData.CustomEpochEra.CustomEra, columnData.CustomEpochEra); + + // is being modified so grab it again every time + var nEraColumns = epochSlotContainer.GetChildren().OfType().ToList(); + + var validEraEntryPoint = FindValidCustomEraEntryPoint(nEraColumns, customEpochModel, columnData, originalNEraColumns); + + BaseLibMain.Logger.Info($"FirstIndex: {validEraEntryPoint.NEraColumnIndex.ToString()}"); + + var searchBefore = validEraEntryPoint.RelativeInsertDirection == RelativeEraDirection.Before; + var curDepth = searchBefore ? -1 : 1; + var curIndex = validEraEntryPoint.NEraColumnIndex + curDepth; + while (curIndex >= 0 && curIndex < nEraColumns.Count) + { + if (originalNEraColumns.Contains(nEraColumns[curIndex])) + { + InsertNewEraColumn(epochSlotContainer, newlyCreatedColumns, uniqueEpochEras, customEpochEra, newNEraColumn, searchBefore ? curIndex + 1 : curIndex); + break; + } + + if (ourCustomColumns.TryGetValue(nEraColumns[curIndex].era, out var era)) + { + if (era.DirectionDepth > validEraEntryPoint.PositionDepth) + { + InsertNewEraColumn(epochSlotContainer, newlyCreatedColumns, uniqueEpochEras, customEpochEra, newNEraColumn, searchBefore ? curIndex + 1 : curIndex); + break; + } + } + + curDepth += Math.Sign(curDepth); + curIndex = validEraEntryPoint.NEraColumnIndex + curDepth; + } + + if (curIndex == -1) // insert at the very left + InsertNewEraColumn(epochSlotContainer, newlyCreatedColumns, uniqueEpochEras, customEpochEra, newNEraColumn, 0); + if (curIndex == nEraColumns.Count) // insert at the very right + InsertNewEraColumn(epochSlotContainer, newlyCreatedColumns, uniqueEpochEras, customEpochEra, newNEraColumn, nEraColumns.Count); + } + } + } + + // I originally wanted to do this by referencing the state before and after inserting the custom epochs, but the container has not yet updated its size + // so instead we do it manually + var largestEpochCount = epochSlotContainer.GetChildren().OfType().ToList().Select(nEraColumn => nEraColumn.GetChildCount() - 1).Prepend(0).Max(); + var overCount = largestEpochCount - 5; + if (overCount <= 0) return; + const float epochSize = -24f; + const float spacing = -32f; + _originalEpochSlotsContainerPosition = new Vector2(0, overCount * epochSize + (overCount - 1) * spacing); + epochSlotContainer.Position += _originalEpochSlotsContainerPosition; + } + + + private static (RelativeEraDirection RelativeInsertDirection, int PositionDepth, int NEraColumnIndex) + FindValidCustomEraEntryPoint(List nEraColumns, CustomEpochModel customEpochModel, + CustomEraColumnData columnData, List originalNEraColumns) + { + var relativeInsertDirection = columnData.CustomEpochEra.Direction; + var positionDepth = customEpochModel.CustomEra!.DirectionDepth; + var referenceEpochEra = columnData.CustomEpochEra.ReferenceEra; + + var nEraColumnIndex = nEraColumns.FirstIndex(e => e.era == referenceEpochEra); + if (nEraColumnIndex != -1) return (relativeInsertDirection, positionDepth, nEraColumnIndex); + + BaseLibMain.Logger.Info($"The required era has not been unlocked yet. Finding alternative entry point."); + for (var i = 0; i < originalNEraColumns.Count; i++) + { + var reverse = customEpochModel.CustomEra.Direction != RelativeEraDirection.Before; + var index = reverse ? originalNEraColumns.Count - i - 1 : 1; + + if ((!reverse && originalNEraColumns[index].era < referenceEpochEra) || + (reverse && originalNEraColumns[index].era > referenceEpochEra)) continue; + + nEraColumnIndex = nEraColumns.IndexOf(originalNEraColumns[index]); + break; + } + + if (nEraColumnIndex != -1) return (relativeInsertDirection, positionDepth, nEraColumnIndex); + // We reached the last era and still nothing. Insert at the end with reversed direction and depth + nEraColumnIndex = nEraColumns.IndexOf((columnData.CustomEpochEra.Direction == RelativeEraDirection.Before) ? originalNEraColumns.Last() : originalNEraColumns.First()); + relativeInsertDirection = relativeInsertDirection == RelativeEraDirection.Before ? RelativeEraDirection.After : RelativeEraDirection.Before; + positionDepth *= -1; + return (relativeInsertDirection, positionDepth, nEraColumnIndex); + } + + private static void InsertNewEraColumn(HBoxContainer epochSlotContainer, List newlyCreatedColumns, + Dictionary uniqueEpochEras, EpochEra epochEra, NEraColumn newNEraColumn, int index) + { + epochSlotContainer.AddChildSafely(newNEraColumn); + epochSlotContainer.MoveChildSafely(newNEraColumn, index); + newlyCreatedColumns.Add(newNEraColumn); + uniqueEpochEras.Add(epochEra, newNEraColumn); + } + + + private static List SortSlotsIntoCustomEraColumns() + { + List columns = []; + foreach (var epochSlotData in _customEpochSlotData) + { + if (epochSlotData.Model is not CustomEpochModel customEpochModel) continue; + if (customEpochModel.CustomEra is null) continue; + + if (columns.Any(c => c.CustomEpochEra == customEpochModel.CustomEra)) + { + var columnData = columns.Where(c => c.CustomEpochEra == customEpochModel.CustomEra).First(); + columnData.EpochSlotData.Add(epochSlotData); + columnData.CustomEpochs.Add(customEpochModel); + } + else + { + var newColumnData = new CustomEraColumnData(customEpochModel.CustomEra); + newColumnData.EpochSlotData.Add(epochSlotData); + newColumnData.CustomEpochs.Add(customEpochModel); + columns.Add(newColumnData); + } + } + + return columns; + } + + private struct CustomEraColumnData(CustomEpochEra customEpochEra) + { + public CustomEpochEra CustomEpochEra { get; init; } = customEpochEra; + public List EpochSlotData = []; + public List CustomEpochs = []; // Save Epochs separate from Data so we don't have to re-cast to the custom Model. + } + + + [HarmonyPatch] + public static class NTimelineScreenVerticalDragging + { + private static bool _allowHorizontalDrag = false; + private static bool _allowVerticalDrag = false; + + [HarmonyPostfix] + [HarmonyPatch(typeof(NTimelineScreen), nameof(NTimelineScreen.SetScreenDraggability))] + private static void ShouldScreenBeDraggableBecauseOfColumnHeight(NTimelineScreen __instance) + { + var slotsContainerFieldObject = SlotsContainerField?.GetValue(__instance); + if (slotsContainerFieldObject is not NSlotsContainer nSlotsContainer) return; + var epochSlotContainerObject = EpochSlotContainerField?.GetValue(__instance); + if (epochSlotContainerObject is not HBoxContainer epochSlotContainer) return; + + _allowHorizontalDrag = nSlotsContainer.MouseFilter == Control.MouseFilterEnum.Stop; + _allowVerticalDrag = false; + if (epochSlotContainer.GetChildren().OfType().ToList().Select(nEraColumn => nEraColumn.GetChildCount() - 1).Prepend(0).Max() <= 5) return; + nSlotsContainer.MouseFilter = Control.MouseFilterEnum.Stop; + _allowVerticalDrag = true; + } + + + private static readonly FieldInfo? DragStartPositionField = AccessTools.Field(typeof(NSlotsContainer), "_dragStartPosition"); + private static readonly FieldInfo? TargetPositionField = AccessTools.Field(typeof(NSlotsContainer), "_targetPosition"); + private static readonly FieldInfo? IsDraggingField = AccessTools.Field(typeof(NSlotsContainer), "_isDragging"); + private static readonly FieldInfo? EpochSlotsField = AccessTools.Field(typeof(NSlotsContainer), "_epochSlots"); + private static readonly FieldInfo? WhatsMovedField = AccessTools.Field(typeof(NSlotsContainer), "_whatsMoved"); + + [HarmonyTranspiler] + [HarmonyPatch(typeof(NSlotsContainer), "ProcessPanEvent")] + private static List AdjustProcessPanEventForVertical(IEnumerable instructions) + { + var setTargetPosition = typeof(NTimelineScreenVerticalDragging).Method(nameof(SetTargetPosition)); + var matcher = new CodeMatcher(instructions) + .MatchStartForward([ + new CodeMatch(OpCodes.Ldloc_1), + new CodeMatch(OpCodes.Callvirt), + new CodeMatch(OpCodes.Ldarg_0), + ]) + .ThrowIfInvalid("Could not find correct position") + .RemoveUntilForward([ + new CodeMatch(OpCodes.Ldarg_0), + new CodeMatch(OpCodes.Ldloc_1), + new CodeMatch(OpCodes.Callvirt), + ]) + .Insert([ + new CodeInstruction(OpCodes.Ldarg_0), + new CodeInstruction(OpCodes.Ldloc_1), + new CodeInstruction(OpCodes.Call, setTargetPosition), + ]); + return matcher.InstructionEnumeration().ToList(); + } + + private static void SetTargetPosition(NSlotsContainer instance, InputEventMouseMotion eventMouseMotion) + { + // This code runs a lot. Might be better to hold a reference in a conditional weak table? + var dragStartPositionObject = DragStartPositionField?.GetValue(instance); + if (dragStartPositionObject is not Vector2 dragStartPosition) throw new NullReferenceException(); + var targetPositionObject = TargetPositionField?.GetValue(instance); + if (targetPositionObject is not Vector2 targetPosition) throw new NullReferenceException(); + + var allowDrag = new Vector2(_allowHorizontalDrag ? 1 : 0, _allowVerticalDrag ? 1 : 0); + TargetPositionField?.SetValue(instance, targetPosition + (eventMouseMotion.Position - dragStartPosition) * allowDrag); + } + + + [HarmonyPostfix] + [HarmonyPatch(typeof(NSlotsContainer), nameof(NSlotsContainer._Process))] + private static void LerpToMinMaxHeights(NSlotsContainer __instance, double delta) + { + var isDraggingObject = IsDraggingField?.GetValue(__instance); + if (isDraggingObject is not bool isDragging) return; + if (!isDragging || !_allowVerticalDrag) return; + + var targetPositionObject = TargetPositionField?.GetValue(__instance); + if (targetPositionObject is not Vector2 targetPosition) throw new NullReferenceException(); + var epochSlotsObject = EpochSlotsField?.GetValue(__instance); + if (epochSlotsObject is not Control epochSlots) throw new NullReferenceException(); + var whatsMovedObject = WhatsMovedField?.GetValue(__instance); + if (whatsMovedObject is not Control whatsMoved) throw new NullReferenceException(); + + var num = targetPosition.Y; + var upperBound = epochSlots.Size.Y - 700; // - whatsMoved.Size.Y; //27 1080 + var lowerBound = -550; // epochSlots.Position.Y + epochSlots.Size.Y - whatsMoved.Size.Y; + if (num > upperBound) + num = Mathf.Lerp(num, upperBound, (float)delta * 36f); + else if (num < lowerBound) + num = Mathf.Lerp(num, lowerBound, (float)delta * 36f); + TargetPositionField?.SetValue(__instance, new Vector2(targetPosition.X, num)); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(NSlotsContainer), nameof(NSlotsContainer.Reset))] + private static void ResetWhatsMovedYPosition(NSlotsContainer __instance) + { + if (!_allowVerticalDrag) return; + var whatsMovedObject = WhatsMovedField?.GetValue(__instance); + if (whatsMovedObject is not Control whatsMoved) throw new NullReferenceException(); + whatsMoved.Position = new Vector2(whatsMoved.Position.X, -540f); + } + + + [HarmonyPostfix] + [HarmonyPatch(typeof(NTimelineScreen), "ResetScreen")] + private static void ResetEpochSlotContainerPosition(NTimelineScreen __instance) + { + if (_originalEpochSlotsContainerPosition == new Vector2(0, 0)) return; + var epochSlotContainerObject = EpochSlotContainerField?.GetValue(__instance); + if (epochSlotContainerObject is not HBoxContainer epochSlotContainer) return; + epochSlotContainer.Position -= _originalEpochSlotsContainerPosition; + _originalEpochSlotsContainerPosition = new Vector2(0, 0); + } + } +} \ No newline at end of file diff --git a/Patches/Content/CustomEpochPatches.cs b/Patches/Content/CustomEpochPatches.cs deleted file mode 100644 index 8637d2fa..00000000 --- a/Patches/Content/CustomEpochPatches.cs +++ /dev/null @@ -1,6 +0,0 @@ -using System.Reflection.Emit; -using BaseLib.Abstracts; -using HarmonyLib; -using MegaCrit.Sts2.Core.Nodes.Screens.Timeline; - -namespace BaseLib.Patches.Content; From a5e03d4df7f1279b52de211b627d64b301e14c3c Mon Sep 17 00:00:00 2001 From: quipsol <245758987+quipsol@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:01:37 +0200 Subject: [PATCH 4/6] very minor changes and comments --- Abstracts/CustomCharacterModel.cs | 5 +- Abstracts/CustomEpochEra.cs | 17 +- Abstracts/CustomEpochModel.cs | 253 ++++++++++++----------- Abstracts/CustomStoryModel.cs | 8 +- Extensions/TypePrefix.cs | 14 -- Patches/Content/ContentPatches.cs | 19 +- Patches/Content/CustomEpochEraPatches.cs | 50 +++-- Patches/PostModInitPatch.cs | 8 +- Utils/CustomEpochHandler.cs | 27 +++ Utils/ReflectionUtils.cs | 51 ++++- 10 files changed, 271 insertions(+), 181 deletions(-) create mode 100644 Utils/CustomEpochHandler.cs diff --git a/Abstracts/CustomCharacterModel.cs b/Abstracts/CustomCharacterModel.cs index 98ff7095..259fcd3c 100644 --- a/Abstracts/CustomCharacterModel.cs +++ b/Abstracts/CustomCharacterModel.cs @@ -258,20 +258,21 @@ private static class CharacterUnlockEpochLogic { // We check it every NMainMenu load because of profile switching, deletion, importing // TODO: There might be a better place to hook this up to. -> Run once on game start and then only when profile changes + // It is a likely scenario that anyone installing mods already has a save file with NeowEpoch unlocked. + // So we check for that and unlock the character epoch immediately. [HarmonyPatch(typeof(NMainMenu), nameof(NMainMenu._Ready))] [HarmonyPrefix] private static void CheckUnlockConditions() { if (!SaveManager.Instance.IsEpochRevealed()) return; foreach (var epochModel in CustomContentDictionary.CustomCharacters - .Where(c => c.UnlockEpoch is not null && c.UnlocksAfterRunAs is null) + .Where(c => c.UnlockEpoch is not null && c.UnlocksAfterRunAs is null or Ironclad) .Select(c => c.UnlockEpoch)) { if (SaveManager.Instance.IsEpochRevealed(epochModel!.Id) || SaveManager.Instance.Progress.IsEpochObtained(epochModel.Id)) continue; SaveManager.Instance.ObtainEpochOverride(epochModel.Id, EpochState.Obtained); } - } } diff --git a/Abstracts/CustomEpochEra.cs b/Abstracts/CustomEpochEra.cs index 52df25bb..16ced37a 100644 --- a/Abstracts/CustomEpochEra.cs +++ b/Abstracts/CustomEpochEra.cs @@ -10,8 +10,12 @@ namespace BaseLib.Abstracts; - - +// Has no entry in CustomContentDictionary since "EpochEra" is actually just an Enum +// and this class exists only to hold all information needed to bypass a lot of +// base games assumptions on how to set eras up. +/// +/// Setup for a custom era column. Still requires a "[CustomEnum] EpochEra" for dictionary entry +/// public abstract class CustomEpochEra { /// @@ -27,6 +31,13 @@ public abstract class CustomEpochEra /// The direction in which the custom era will be inserted. /// public abstract RelativeEraDirection Direction { get; } + + + // Duplicate numbers from different mods (or the same mod) won't cause any issues here. + // They will simply be placed next to each other in a random order. + // If a mod creator wants to make sure their eras are directly next to each other without + // eras from a different mod mixed in, just pick a random starting number. + // E.g. Don't use 0,1,2 but -300,-299,-298 or 3247822, 3247823, 3247824. /// /// Used for ordering multiple custom eras at the same Reference Era and . /// Larger numbers are further away from the reference era. @@ -58,7 +69,6 @@ private static class Patches [HarmonyPatch(typeof(NEraColumn), nameof(NEraColumn.Init))] private static void ReplaceIconForCustomEraColumn(NEraColumn __instance, EpochSlotData epochSlot) { - BaseLibMain.Logger.Info("ReplaceIconForCustomEraColumn"); // We assume in a custom Era can only be CustomEpochs if (epochSlot.Model is not CustomEpochModel customEpochModel) return; if (customEpochModel.CustomEra is null) return; @@ -69,7 +79,6 @@ private static void ReplaceIconForCustomEraColumn(NEraColumn __instance, EpochSl if (textureRect.Texture is null) textureRect.Visible = true; // set to false at some point if the original method cant get a proper texture textureRect.Texture = customEpochModel.CustomEra.EraIconTexture; - BaseLibMain.Logger.Info($"{textureRect.Texture.ResourcePath}"); if (customEpochModel.CustomEra.UseOriginalImageSize) { var originalSize = customEpochModel.CustomEra.EraIconTexture.GetSize(); diff --git a/Abstracts/CustomEpochModel.cs b/Abstracts/CustomEpochModel.cs index cb5bbedc..22cbaa41 100644 --- a/Abstracts/CustomEpochModel.cs +++ b/Abstracts/CustomEpochModel.cs @@ -21,7 +21,7 @@ namespace BaseLib.Abstracts; /// -/// These are not stored in +/// Despite the name these are not stored in /// public abstract class CustomEpochModel : EpochModel, ICustomModel { @@ -42,8 +42,7 @@ public abstract class CustomEpochModel : EpochModel, ICustomModel /// The small image displayed on the Timeline. /// public abstract string CustomPackedPortraitPath { get; } // tres - - // TODO: See if this can be foregone by checking the "Era" property for a custom era. + /// /// Override only if this Epoch is part of a Custom Era. /// @@ -52,7 +51,7 @@ public abstract class CustomEpochModel : EpochModel, ICustomModel /// /// Set to true if your epoch unlocks cards so it can be added to /// - public virtual bool UnlocksCards => false; + protected virtual bool UnlocksCards => false; [HarmonyPatch] private static class PropertyRedirections @@ -79,7 +78,7 @@ private static bool CustomEpochPackedPortraitPath(EpochModel __instance, ref str /// /// Should only be called once from /// - public static void FillEpochDictionaries(List models) + internal static void FillEpochDictionaries(List models) { BaseLibMain.Logger.Info("Inserting CustomEpochs into dictionaries"); var epochModelType = typeof(EpochModel); @@ -87,6 +86,7 @@ public static void FillEpochDictionaries(List models) var typeToIdDictionary = (AccessTools.Field(epochModelType, "_typeToIdDictionary").GetValue(null) as Dictionary)!; foreach (var customEpochModel in models) { + CustomEpochHandler.InsertIntoAllEpochs(customEpochModel); var type = customEpochModel.GetType(); BaseLibMain.Logger.Debug($"CustomEpoch Type: {type.Name} | Id: {customEpochModel.Id} "); CustomContentDictionary.AddEpoch(customEpochModel); @@ -99,6 +99,7 @@ public static void FillEpochDictionaries(List models) [HarmonyPatch] public static class CustomEpochPatches { + // We manipulate EraPositions only in the live Timeline, not anywhere where it would be permanent. [HarmonyPatch] public static class DuplicateEraPositionHandler { @@ -113,17 +114,12 @@ private static List AdjustNEpochSlotEraPosition(IEnumerable OpCode: {list[i].opcode} | Operand: {list[i].operand}"); - } return matcher.InstructionEnumeration().ToList(); } @@ -132,14 +128,15 @@ private static void EnforceUniqueEraPosition(NEraColumn nEraColumn, NEpochSlot n var allCurrentNEpochSlots = nEraColumn.GetChildren().OfType().ToList(); var oldEraPosition = nEpochSlot.eraPosition; while (allCurrentNEpochSlots.Any(o => o.eraPosition == nEpochSlot.eraPosition)) - { nEpochSlot.eraPosition++; - } if (oldEraPosition == nEpochSlot.eraPosition) return; OverwrittenEraPositions.Set(nEpochSlot, true); BaseLibMain.Logger.Info($"Moved EpochSlot position for Epoch {nEpochSlot.model.Id} from {oldEraPosition} to {nEpochSlot.eraPosition}"); } + + // The game checks for a match with the position, however we manipulated (some of) them above. + // Replaces the ".position == .position" check with an ".Id == .Id" check. [HarmonyPatch(typeof(NTimelineScreen), nameof(NTimelineScreen.InitScreen), MethodType.Async)] [HarmonyTranspiler] private static List ResolveSetStateForNewEraPositions(IEnumerable instructions) @@ -149,9 +146,8 @@ private static List ResolveSetStateForNewEraPositions(IEnumerab .MatchStartForward([ new CodeMatch(OpCodes.Isinst), ]) - .ThrowIfInvalid("Could not find correct position") + .ThrowIfInvalid("Could not find correct position to begin replacing EraPosition comparision check") .Advance(2); - var nEpochSlot = matcher.Instruction.operand; matcher.Advance(1); var target = (Label)matcher.Instruction.operand; @@ -194,130 +190,141 @@ private static void LockCharactersWithUnlockEpoch(UnlockState __instance, ref IE private static readonly MethodInfo? TryObtainEpochMidRunMethod = typeof(ProgressSaveManager) .GetMethod("TryObtainEpochMidRun", BindingFlags.Instance | BindingFlags.NonPublic); - public static readonly MethodInfo? TryObtainEpochPostRunMethod = typeof(ProgressSaveManager) + // used once in CustomCharacterModel + internal static readonly MethodInfo? TryObtainEpochPostRunMethod = typeof(ProgressSaveManager) .GetMethod("TryObtainEpochPostRun", BindingFlags.Instance | BindingFlags.NonPublic); private static readonly MethodInfo? GetEliteEncountersMethod = typeof(ProgressSaveManager) .GetMethod("GetEliteEncounters", BindingFlags.Static | BindingFlags.NonPublic); - - // These skips would always skip, even if the creator intended for their own way to implement them! - // For now, I will leave it as is because adding booleans to the CustomCharacterModel in case someone might want to use these Epochs - // but does not want to set them in the characters class, is unnecessary. (it isn't too difficult to counter patch either. The main issue - // is figuring out this was the reason for them not running which is helped by this comment here. Hello there!) - [HarmonyPatch(typeof(ProgressSaveManager), "ObtainCharUnlockEpoch")] - [HarmonyPrefix] - private static bool SkipCharUnlockEpochIfUnsupported(ProgressSaveManager __instance, Player localPlayer, int act) + + + [HarmonyPatch] + public static class CharacterEpochsUnlockPatches { - if (localPlayer.Character is not CustomCharacterModel ccm) - return true; - switch (act) + // These skips would always skip, even if the creator intended for their own way to implement them! + // For now, I will leave it as is because adding booleans to the CustomCharacterModel in case someone might want to use these Epochs + // but does not want to set them in the characters class, is unnecessary. (it isn't difficult to patch around this. The main issue + // is figuring out these patches are the reason for them not running which is helped by this comment here. Hello there!) + + // CharUnlock is actually the beat Act1-3 epoch unlocks, not unlocking a character + // The UnlockCharacter Epoch is patched in CustomCharacterModel as it requires access to a private property. + [HarmonyPatch(typeof(ProgressSaveManager), "ObtainCharUnlockEpoch")] + [HarmonyPrefix] + private static bool SkipCharUnlockEpochIfUnsupported(ProgressSaveManager __instance, Player localPlayer, int act) { - case 0: - if(ccm.Act1Epoch is not null) - TryObtainEpochMidRunMethod?.Invoke(__instance, [ccm.Act1Epoch, localPlayer]); - break; - case 1: - if(ccm.Act2Epoch is not null) - TryObtainEpochMidRunMethod?.Invoke(__instance, [ccm.Act2Epoch, localPlayer]); - break; - case 2: - if(ccm.Act3Epoch is not null) - TryObtainEpochMidRunMethod?.Invoke(__instance, [ccm.Act3Epoch, localPlayer]); - break; - case 3: - BaseLibMain.Logger.Warn("BaseLib does not support Act 4 yet."); - break; - default: - BaseLibMain.Logger.Warn($"Unsupported Act: {act}"); - break; - } - return false; - } - - - // This and the two following patches currently copy the entire methods logic into the prefix - // In the case of 15 bosses and elites it might be better to just transpile at the "throw" to - // check for a custom character with an epoch. - // Since these two methods store the epoch object in a variable unlike the Act1-3 - // check which runs a switch on the act number and then builds the Id using strings. - - [HarmonyPatch(typeof(ProgressSaveManager), "CheckFifteenBossesDefeatedEpoch")] - [HarmonyPrefix] - private static bool SkipBossEpochIfUnsupported(ProgressSaveManager __instance, Player localPlayer) - { - if(localPlayer.Character is not CustomCharacterModel customCharacterModel) - return true; - if(!localPlayer.Character.IsPlayable || customCharacterModel.FifteenBossesEpoch is null) + if (localPlayer.Character is not CustomCharacterModel ccm) + return true; + switch (act) + { + case 0: + if (ccm.Act1Epoch is not null) + TryObtainEpochMidRunMethod?.Invoke(__instance, [ccm.Act1Epoch, localPlayer]); + break; + case 1: + if (ccm.Act2Epoch is not null) + TryObtainEpochMidRunMethod?.Invoke(__instance, [ccm.Act2Epoch, localPlayer]); + break; + case 2: + if (ccm.Act3Epoch is not null) + TryObtainEpochMidRunMethod?.Invoke(__instance, [ccm.Act3Epoch, localPlayer]); + break; + case 3: + BaseLibMain.Logger.Warn("BaseLib does not support Act 4 yet."); + break; + default: + BaseLibMain.Logger.Warn($"Unsupported Act: {act}"); + break; + } + return false; - - var bossEncounters = ModelDb.Acts.SelectMany(a => a.AllBossEncounters.Select(e => e.Id)).ToHashSet(); - - var num = 0; - foreach (var encounterStats in __instance.Progress.EncounterStats.Values) + } + + + // This and the two following patches currently copy the entire methods logic into the prefix. + // In the case of 15 bosses and elites it might be better to just transpile at the "throw" to + // check for a custom character with an epoch. Since these two methods store the epoch object + // in a variable, unlike the Act1-3 check which runs a switch on the act number and then builds + // the Id using strings. + + [HarmonyPatch(typeof(ProgressSaveManager), "CheckFifteenBossesDefeatedEpoch")] + [HarmonyPrefix] + private static bool SkipBossEpochIfUnsupported(ProgressSaveManager __instance, Player localPlayer) { - if (!bossEncounters.Contains(encounterStats.Id)) continue; - foreach (var fightStat in encounterStats.FightStats.Where(fightStat => fightStat.Character == customCharacterModel.Id)) + if (localPlayer.Character is not CustomCharacterModel customCharacterModel) + return true; + if (!localPlayer.Character.IsPlayable || customCharacterModel.FifteenBossesEpoch is null) + return false; + + var bossEncounters = ModelDb.Acts.SelectMany(a => a.AllBossEncounters.Select(e => e.Id)).ToHashSet(); + + var num = 0; + foreach (var encounterStats in __instance.Progress.EncounterStats.Values) { - num += fightStat.Wins; - break; + if (!bossEncounters.Contains(encounterStats.Id)) continue; + foreach (var fightStat in encounterStats.FightStats.Where(fightStat => fightStat.Character == customCharacterModel.Id)) + { + num += fightStat.Wins; + break; + } } - } - if (num < 15) - return false; - var res = TryObtainEpochMidRunMethod?.Invoke(__instance, [customCharacterModel.FifteenBossesEpoch, localPlayer]); - if(res is true) - BaseLibMain.Logger.Info($"Epoch obtained for beating 15 Bosses"); - return false; - } - - [HarmonyPatch(typeof(ProgressSaveManager), "CheckFifteenElitesDefeatedEpoch")] - [HarmonyPrefix] - private static bool SkipEliteEpochIfUnsupported(ProgressSaveManager __instance, Player localPlayer) - { - if(localPlayer.Character is not CustomCharacterModel customCharacterModel) - return true; - if(!localPlayer.Character.IsPlayable || customCharacterModel.FifteenElitesEpoch is null) + + if (num < 15) + return false; + var res = TryObtainEpochMidRunMethod?.Invoke(__instance, [customCharacterModel.FifteenBossesEpoch, localPlayer]); + if (res is true) + BaseLibMain.Logger.Info($"Epoch obtained for beating 15 Bosses"); return false; - - var eliteEncountersObject = GetEliteEncountersMethod?.Invoke(null, []) ?? throw new NullReferenceException(); - if (eliteEncountersObject is not HashSet eliteEncounters) return false; - - var num = 0; - foreach (var encounterStats in __instance.Progress.EncounterStats.Values) + } + + [HarmonyPatch(typeof(ProgressSaveManager), "CheckFifteenElitesDefeatedEpoch")] + [HarmonyPrefix] + private static bool SkipEliteEpochIfUnsupported(ProgressSaveManager __instance, Player localPlayer) { - if (!eliteEncounters.Contains(encounterStats.Id)) continue; - foreach (var fightStat in encounterStats.FightStats.Where(fightStat => fightStat.Character == customCharacterModel.Id)) + if (localPlayer.Character is not CustomCharacterModel customCharacterModel) + return true; + if (!localPlayer.Character.IsPlayable || customCharacterModel.FifteenElitesEpoch is null) + return false; + + var eliteEncountersObject = GetEliteEncountersMethod?.Invoke(null, []) ?? throw new NullReferenceException(); + if (eliteEncountersObject is not HashSet eliteEncounters) return false; + + var num = 0; + foreach (var encounterStats in __instance.Progress.EncounterStats.Values) { - num += fightStat.Wins; - break; + if (!eliteEncounters.Contains(encounterStats.Id)) continue; + foreach (var fightStat in encounterStats.FightStats.Where(fightStat => fightStat.Character == customCharacterModel.Id)) + { + num += fightStat.Wins; + break; + } } - } - if (num < 15) + + if (num < 15) + return false; + var res = TryObtainEpochMidRunMethod?.Invoke(__instance, [customCharacterModel.FifteenElitesEpoch, localPlayer]); + if (res is true) + BaseLibMain.Logger.Info($"Epoch obtained for beating 15 Elites"); return false; - var res = TryObtainEpochMidRunMethod?.Invoke(__instance, [customCharacterModel.FifteenElitesEpoch, localPlayer]); - if(res is true) - BaseLibMain.Logger.Info($"Epoch obtained for beating 15 Elites"); - return false; - } - - [HarmonyPatch(typeof(ProgressSaveManager), "CheckAscensionOneCompleted")] - [HarmonyPrefix] - private static bool SkipAscensionOneEpochIfUnsupported(ProgressSaveManager __instance, SerializablePlayer serializablePlayer, SerializableRun serializableRun) - { - var characterModel = ModelDb.GetById(serializablePlayer.CharacterId!); - if(characterModel is not CustomCharacterModel customCharacterModel) - return true; - if (serializableRun.Ascension != 1 || customCharacterModel.AscensionOneEpoch is null) + } + + [HarmonyPatch(typeof(ProgressSaveManager), "CheckAscensionOneCompleted")] + [HarmonyPrefix] + private static bool SkipAscensionOneEpochIfUnsupported(ProgressSaveManager __instance, SerializablePlayer serializablePlayer, SerializableRun serializableRun) + { + var characterModel = ModelDb.GetById(serializablePlayer.CharacterId!); + if (characterModel is not CustomCharacterModel customCharacterModel) + return true; + if (serializableRun.Ascension != 1 || customCharacterModel.AscensionOneEpoch is null) + return false; + var res = TryObtainEpochPostRunMethod?.Invoke(__instance, [customCharacterModel.AscensionOneEpoch, serializablePlayer, serializableRun]); + if (res is true) + BaseLibMain.Logger.Info($"Epoch obtained for beating Ascension 1"); return false; - var res = TryObtainEpochPostRunMethod?.Invoke(__instance, [customCharacterModel.AscensionOneEpoch, serializablePlayer, serializableRun]); - if(res is true) - BaseLibMain.Logger.Info($"Epoch obtained for beating Ascension 1"); - return false; + } + } - - - - - + + + } } \ No newline at end of file diff --git a/Abstracts/CustomStoryModel.cs b/Abstracts/CustomStoryModel.cs index 35d8a7a6..9accd1d5 100644 --- a/Abstracts/CustomStoryModel.cs +++ b/Abstracts/CustomStoryModel.cs @@ -11,7 +11,7 @@ namespace BaseLib.Abstracts; /// -/// These are not stored in +/// Despite the name these are not stored in /// public abstract class CustomStoryModel : StoryModel, ICustomModel { @@ -28,16 +28,16 @@ public abstract class CustomStoryModel : StoryModel, ICustomModel /// /// Should only be called once from /// - public static void FillStoryDictionary(List models) + internal static void FillStoryDictionaries(List models) { - BaseLibMain.Logger.Info("Inserting CustomStories into dictionary"); + BaseLibMain.Logger.Info("Inserting CustomStories into dictionaries"); var storyTypeDictionary = (AccessTools.Field(typeof(StoryModel), "_storyTypeDictionary").GetValue(null) as Dictionary)!; foreach (var customStoryModel in models) { var type = customStoryModel.GetType(); BaseLibMain.Logger.Debug($"CustomStory Type: {type.Name} | Id: {customStoryModel.Id} | Saved in dict as: {StringHelper.Slugify(customStoryModel.Id)}"); CustomContentDictionary.AddStory(customStoryModel); - // slugify it to match what the game does for lookup even if it currently removes the prefix - + // slugify it to match what the game does for lookup even if it currently removes the prefix '-' storyTypeDictionary[StringHelper.Slugify(customStoryModel.Id)] = type; } } diff --git a/Extensions/TypePrefix.cs b/Extensions/TypePrefix.cs index d66ba7ff..4cd2f58e 100644 --- a/Extensions/TypePrefix.cs +++ b/Extensions/TypePrefix.cs @@ -12,20 +12,6 @@ public static string GetPrefix(this Type t) dotIndex = t.Namespace.Length; return $"{t.Namespace[..dotIndex].ToUpperInvariant()}{PrefixSplitChar}"; } - - /// - /// Returns the Mods Prefix without modifying it - /// - /// - public static string GetRawPrefix(this Type t, bool includePrefixSplitCharacter = false) - { - if (t.Namespace == null) - return ""; - var dotIndex = t.Namespace.IndexOf('.'); - if (dotIndex == -1) - dotIndex = t.Namespace.Length; - return $"{t.Namespace[..dotIndex]}{(includePrefixSplitCharacter ? PrefixSplitChar : "")}"; - } public static string GetRootNamespace(this Type t) { diff --git a/Patches/Content/ContentPatches.cs b/Patches/Content/ContentPatches.cs index 0410047f..3c7378ff 100644 --- a/Patches/Content/ContentPatches.cs +++ b/Patches/Content/ContentPatches.cs @@ -430,15 +430,20 @@ public class EpochModelCustomEpochsPatch { [HarmonyPatch(typeof(EpochModel), nameof(EpochModel.AllEpochIds), MethodType.Getter)] [HarmonyPostfix] - private static void InsertEpochIds(EpochModel __instance, ref IReadOnlyList __result) + private static void InsertEpochIds(ref IReadOnlyList __result) { __result = [.. __result, .. CustomContentDictionary.CustomEpochs.Select(x => x.Id)]; } - [HarmonyPatch(typeof(EpochModel), nameof(EpochModel.AllEpochs), MethodType.Getter)] - [HarmonyPostfix] - private static void InsertEpochTypes(EpochModel __instance, ref IReadOnlyList __result) - { - __result = [.. __result, .. CustomContentDictionary.CustomEpochs.Select(x => x.GetType())]; - } + // Not needed anymore. + // The reason for this is that when 'ModelIdSerializationCache' Initializes, this harmony patch isn't + // running yet. + // So instead we insert them during PostModInit directly into the _allEpochs list. + + // [HarmonyPatch(typeof(EpochModel), nameof(EpochModel.AllEpochs), MethodType.Getter)] + // [HarmonyPostfix] + // private static void InsertEpochTypes(ref IReadOnlyList __result) + // { + // __result = [.. __result, .. CustomContentDictionary.CustomEpochs.Select(x => x.GetType())]; + // } } \ No newline at end of file diff --git a/Patches/Content/CustomEpochEraPatches.cs b/Patches/Content/CustomEpochEraPatches.cs index e7af65c1..46766da4 100644 --- a/Patches/Content/CustomEpochEraPatches.cs +++ b/Patches/Content/CustomEpochEraPatches.cs @@ -13,13 +13,21 @@ namespace BaseLib.Patches.Content; /// -/// Massive patch to hand insertion of entirely new Eras +/// Massive patch to handle insertion of entirely new Eras and their respective Epochs /// [HarmonyPatch] public class AddCustomEpochSlots { + /// + /// Our custom Epochs that belong to a custom Era.
+ /// Removed from in a Prefix and later inserted via Transpiler. + ///
private static List _customEpochSlotData = []; - private static Vector2 _originalEpochSlotsContainerPosition = new(0, 0); + /// + /// If too many Epochs are in one Era (more than 5) the epochSlotsContainer must be offset + /// to properly align the Timeline line at the bottom. + /// + private static Vector2 _epochSlotsContainerPositionOffset = new(0, 0); private static readonly FieldInfo? UniqueEpochErasField = AccessTools.Field(typeof(NTimelineScreen), "_uniqueEpochEras"); private static readonly FieldInfo? EpochSlotContainerField = AccessTools.Field(typeof(NTimelineScreen), "_epochSlotContainer"); @@ -61,24 +69,19 @@ private static List InsertCustomEraColumnsAndEpochs(IEnumerable private static void CustomEpochEraHandler(NTimelineScreen instance, List newlyCreatedColumns) { - BaseLibMain.Logger.Warn($"Called {nameof(CustomEpochEraHandler)}"); - BaseLibMain.Logger.Warn($"List count: {newlyCreatedColumns.Count}"); + BaseLibMain.Logger.VeryDebug($"Called {nameof(CustomEpochEraHandler)}"); if (UniqueEpochErasField is null || EpochSlotContainerField is null) return; var uniqueEpochErasObject = UniqueEpochErasField.GetValue(instance); var epochSlotContainerObject = EpochSlotContainerField.GetValue(instance); - if (uniqueEpochErasObject == null || epochSlotContainerObject == null) return; if (uniqueEpochErasObject is not Dictionary uniqueEpochEras) return; if (epochSlotContainerObject is not HBoxContainer epochSlotContainer) return; - BaseLibMain.Logger.Warn("Reflection successful"); - var customEraColumnData = SortSlotsIntoCustomEraColumns(); var originalNEraColumns = epochSlotContainer.GetChildren().OfType().ToList(); var ourCustomColumns = new Dictionary(); - // For every column we have foreach (var columnData in customEraColumnData) { var customEpochEra = columnData.CustomEpochEra.CustomEra; @@ -97,13 +100,13 @@ private static void CustomEpochEraHandler(NTimelineScreen instance, List().ToList(); var validEraEntryPoint = FindValidCustomEraEntryPoint(nEraColumns, customEpochModel, columnData, originalNEraColumns); + BaseLibMain.Logger.Debug($"ValidEraEntryPoint: Direction={validEraEntryPoint.RelativeInsertDirection} | Index={validEraEntryPoint.NEraColumnIndex} | Depth={validEraEntryPoint.PositionDepth}"); - BaseLibMain.Logger.Info($"FirstIndex: {validEraEntryPoint.NEraColumnIndex.ToString()}"); - + // Insert custom era var searchBefore = validEraEntryPoint.RelativeInsertDirection == RelativeEraDirection.Before; var curDepth = searchBefore ? -1 : 1; var curIndex = validEraEntryPoint.NEraColumnIndex + curDepth; @@ -136,18 +139,25 @@ private static void CustomEpochEraHandler(NTimelineScreen instance, List().ToList().Select(nEraColumn => nEraColumn.GetChildCount() - 1).Prepend(0).Max(); var overCount = largestEpochCount - 5; if (overCount <= 0) return; const float epochSize = -24f; const float spacing = -32f; - _originalEpochSlotsContainerPosition = new Vector2(0, overCount * epochSize + (overCount - 1) * spacing); - epochSlotContainer.Position += _originalEpochSlotsContainerPosition; + _epochSlotsContainerPositionOffset = new Vector2(0, overCount * epochSize + (overCount - 1) * spacing); + epochSlotContainer.Position += _epochSlotsContainerPositionOffset; } - + /// + /// Finds a new entry point that keeps the expected position of the custom era in the Timeline the same.
+ /// Eras are only added to the Timeline if at least one Epoch from it is visible. + /// This means our reference Era might not exist.
+ /// During Timeline unlocks custom eras might misalign when new base eras get added. This is fixed automatically when + /// exiting and re-entering the Timeline. -> Not worth fixing. + ///
+ /// Tuple of relevant information to place custom era in Timeline private static (RelativeEraDirection RelativeInsertDirection, int PositionDepth, int NEraColumnIndex) FindValidCustomEraEntryPoint(List nEraColumns, CustomEpochModel customEpochModel, CustomEraColumnData columnData, List originalNEraColumns) @@ -200,7 +210,7 @@ private static List SortSlotsIntoCustomEraColumns() if (columns.Any(c => c.CustomEpochEra == customEpochModel.CustomEra)) { - var columnData = columns.Where(c => c.CustomEpochEra == customEpochModel.CustomEra).First(); + var columnData = columns.First(c => c.CustomEpochEra == customEpochModel.CustomEra); columnData.EpochSlotData.Add(epochSlotData); columnData.CustomEpochs.Add(customEpochModel); } @@ -331,11 +341,11 @@ private static void ResetWhatsMovedYPosition(NSlotsContainer __instance) [HarmonyPatch(typeof(NTimelineScreen), "ResetScreen")] private static void ResetEpochSlotContainerPosition(NTimelineScreen __instance) { - if (_originalEpochSlotsContainerPosition == new Vector2(0, 0)) return; + if (_epochSlotsContainerPositionOffset == new Vector2(0, 0)) return; var epochSlotContainerObject = EpochSlotContainerField?.GetValue(__instance); if (epochSlotContainerObject is not HBoxContainer epochSlotContainer) return; - epochSlotContainer.Position -= _originalEpochSlotsContainerPosition; - _originalEpochSlotsContainerPosition = new Vector2(0, 0); + epochSlotContainer.Position -= _epochSlotsContainerPositionOffset; + _epochSlotsContainerPositionOffset = new Vector2(0, 0); } } } \ No newline at end of file diff --git a/Patches/PostModInitPatch.cs b/Patches/PostModInitPatch.cs index f34226ee..3f6aba0d 100644 --- a/Patches/PostModInitPatch.cs +++ b/Patches/PostModInitPatch.cs @@ -10,6 +10,7 @@ using MegaCrit.Sts2.Core.Helpers; using MegaCrit.Sts2.Core.Localization; using MegaCrit.Sts2.Core.Modding; +using MegaCrit.Sts2.Core.Multiplayer.Serialization; using MegaCrit.Sts2.Core.Saves.Runs; using MegaCrit.Sts2.Core.Timeline; using SmartFormat; @@ -143,7 +144,10 @@ private static void CheckSpecialSpireField(FieldInfo field) private static void InsertEpochSystemRelevantInformationIntoDictionaries() { - CustomStoryModel.FillStoryDictionary(ReflectionUtils.GetListOfInstantiatedSubclasses()); - CustomEpochModel.FillEpochDictionaries(ReflectionUtils.GetListOfInstantiatedSubclasses()); + CustomStoryModel.FillStoryDictionaries(ReflectionUtils.GetListOfInstantiatedSubclassesFromAllAssemblies()); + CustomEpochModel.FillEpochDictionaries(ReflectionUtils.GetListOfInstantiatedSubclassesFromAllAssemblies()); + } + + } \ No newline at end of file diff --git a/Utils/CustomEpochHandler.cs b/Utils/CustomEpochHandler.cs new file mode 100644 index 00000000..76ea57c9 --- /dev/null +++ b/Utils/CustomEpochHandler.cs @@ -0,0 +1,27 @@ +using HarmonyLib; +using MegaCrit.Sts2.Core.Timeline; + +namespace BaseLib.Utils; + +public static class CustomEpochHandler +{ + + private static readonly List AllEpochs = AccessTools.StaticFieldRefAccess>(typeof(EpochModel), "_allEpochs"); + + /// + /// Insert the epoch type into + /// + public static void InsertIntoAllEpochs(EpochModel model) + { + if (!AllEpochs.Contains(model.GetType())) + AllEpochs.Add(model.GetType()); + } + /// + /// Insert the epochs type into + /// + public static void InsertIntoAllEpochs(IEnumerable models) + { + foreach (var model in models) + InsertIntoAllEpochs(model); + } +} \ No newline at end of file diff --git a/Utils/ReflectionUtils.cs b/Utils/ReflectionUtils.cs index d346f5b7..480673c1 100644 --- a/Utils/ReflectionUtils.cs +++ b/Utils/ReflectionUtils.cs @@ -1,4 +1,5 @@ -using System.Reflection; +using System.Reflection; +using System.Runtime.CompilerServices; namespace BaseLib.Utils; @@ -63,12 +64,12 @@ public static class ReflectionUtils } /// - /// Returns a list of instantiated objects, one for each class that inherits from the specified superclass.
+ /// Returns a list of instantiated objects. One for (and of) each class that inherits from the specified superclass.
/// Classes require a parameterless constructor. ///
- /// + /// The Type of the Superclass /// - public static List GetListOfInstantiatedSubclasses() where T : class + public static List GetListOfInstantiatedSubclassesFromAllAssemblies() where T : class { var baseType = typeof(T); var instances = new List(); @@ -102,11 +103,51 @@ public static List GetListOfInstantiatedSubclasses() where T : class } catch (Exception ex) { - BaseLibMain.Logger.Warn($"Mod assembly {assembly.GetName().Name} failed to instantiate type {type.FullName} for base {baseType.Name}. Error: {ex.Message}"); + BaseLibMain.Logger.Error($"Mod assembly {assembly.GetName().Name} failed to instantiate type {type.FullName} for base {baseType.Name}. Error: {ex.Message}"); } } } return instances; } + + /// + /// Returns a list of instantiated objects. One for (and of) each class that inherits from the specified superclass within the calling assembly.
+ /// Classes require a parameterless constructor. + ///
+ /// The Type of the Superclass + /// + [MethodImpl(MethodImplOptions.NoInlining)] + public static List GetListOfInstantiatedSubclassesFromCurrentAssemblies() where T : class + { + var baseType = typeof(T); + var instances = new List(); + var assembly = Assembly.GetCallingAssembly(); + + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException e) + { + types = e.Types.Where(t => t != null).ToArray()!; + } + + foreach (var type in types) + { + if (type is not { IsClass: true, IsAbstract: false } || !baseType.IsAssignableFrom(type)) continue; + try + { + if (type.GetConstructor(Type.EmptyTypes) != null && Activator.CreateInstance(type) is T instance) + instances.Add(instance); + } + catch (Exception ex) + { + BaseLibMain.Logger.Error($"Mod assembly {assembly.GetName().Name} failed to instantiate type {type.FullName} for base {baseType.Name}. Error: {ex.Message}"); + } + } + + return instances; + } } \ No newline at end of file From 5d8d60c489a3918c2a583bd0e961650835cdea3d Mon Sep 17 00:00:00 2001 From: quipsol <245758987+quipsol@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:36:25 +0200 Subject: [PATCH 5/6] manual characer epoch unlock fix now not only checks for neowepoch --- Abstracts/CustomCharacterModel.cs | 44 +++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/Abstracts/CustomCharacterModel.cs b/Abstracts/CustomCharacterModel.cs index 259fcd3c..34cfd2d2 100644 --- a/Abstracts/CustomCharacterModel.cs +++ b/Abstracts/CustomCharacterModel.cs @@ -258,20 +258,48 @@ private static class CharacterUnlockEpochLogic { // We check it every NMainMenu load because of profile switching, deletion, importing // TODO: There might be a better place to hook this up to. -> Run once on game start and then only when profile changes - // It is a likely scenario that anyone installing mods already has a save file with NeowEpoch unlocked. + // It is a likely scenario that anyone installing mods already has a save file with epochs unlocked. // So we check for that and unlock the character epoch immediately. [HarmonyPatch(typeof(NMainMenu), nameof(NMainMenu._Ready))] [HarmonyPrefix] private static void CheckUnlockConditions() { - if (!SaveManager.Instance.IsEpochRevealed()) return; - foreach (var epochModel in CustomContentDictionary.CustomCharacters - .Where(c => c.UnlockEpoch is not null && c.UnlocksAfterRunAs is null or Ironclad) - .Select(c => c.UnlockEpoch)) + foreach (var characterModel in CustomContentDictionary.CustomCharacters + .Where(c => c.UnlockEpoch is not null)) { - if (SaveManager.Instance.IsEpochRevealed(epochModel!.Id) - || SaveManager.Instance.Progress.IsEpochObtained(epochModel.Id)) continue; - SaveManager.Instance.ObtainEpochOverride(epochModel.Id, EpochState.Obtained); + if(SaveManager.Instance.IsEpochRevealed(characterModel.UnlockEpoch!.Id) + || SaveManager.Instance.Progress.IsEpochObtained(characterModel.UnlockEpoch!.Id)) continue; + + switch (characterModel.UnlocksAfterRunAs) + { + case null: + case Ironclad: + if(SaveManager.Instance.IsEpochRevealed()) + SaveManager.Instance.ObtainEpochOverride(characterModel.UnlockEpoch!.Id, EpochState.Obtained); + break; + case Silent: + if(SaveManager.Instance.IsEpochRevealed()) + SaveManager.Instance.ObtainEpochOverride(characterModel.UnlockEpoch!.Id, EpochState.Obtained); + break; + case Regent: + if(SaveManager.Instance.IsEpochRevealed()) + SaveManager.Instance.ObtainEpochOverride(characterModel.UnlockEpoch!.Id, EpochState.Obtained); + break; + case Necrobinder: + if(SaveManager.Instance.IsEpochRevealed()) + SaveManager.Instance.ObtainEpochOverride(characterModel.UnlockEpoch!.Id, EpochState.Obtained); + break; + case Defect: + if(SaveManager.Instance.IsEpochRevealed()) + SaveManager.Instance.ObtainEpochOverride(characterModel.UnlockEpoch!.Id, EpochState.Obtained); + break; + } + + if (characterModel.UnlocksAfterRunAs is not CustomCharacterModel unlocksAfterCustomCharacter) + continue; + if(unlocksAfterCustomCharacter.UnlockEpoch is not null + && SaveManager.Instance.IsEpochRevealed(unlocksAfterCustomCharacter.UnlockEpoch.Id)) + SaveManager.Instance.ObtainEpochOverride(characterModel.UnlockEpoch!.Id, EpochState.Obtained); } } } From 4a2cc3ebd73a4cb7d42a95d4a1fc5fa8eb532ae9 Mon Sep 17 00:00:00 2001 From: quipsol <245758987+quipsol@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:32:56 +0200 Subject: [PATCH 6/6] Add HarmonyPriority These prefixes potentially return false. If someone wants to implement their own logic for their character epochs and prefixes these methods, they might get skipped based on harmony patch order. Set to Priority 0 to ensure their patches run first. --- Abstracts/CustomEpochModel.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Abstracts/CustomEpochModel.cs b/Abstracts/CustomEpochModel.cs index 22cbaa41..e14d0b81 100644 --- a/Abstracts/CustomEpochModel.cs +++ b/Abstracts/CustomEpochModel.cs @@ -200,15 +200,11 @@ private static void LockCharactersWithUnlockEpoch(UnlockState __instance, ref IE [HarmonyPatch] public static class CharacterEpochsUnlockPatches { - // These skips would always skip, even if the creator intended for their own way to implement them! - // For now, I will leave it as is because adding booleans to the CustomCharacterModel in case someone might want to use these Epochs - // but does not want to set them in the characters class, is unnecessary. (it isn't difficult to patch around this. The main issue - // is figuring out these patches are the reason for them not running which is helped by this comment here. Hello there!) - // CharUnlock is actually the beat Act1-3 epoch unlocks, not unlocking a character // The UnlockCharacter Epoch is patched in CustomCharacterModel as it requires access to a private property. [HarmonyPatch(typeof(ProgressSaveManager), "ObtainCharUnlockEpoch")] [HarmonyPrefix] + [HarmonyPriority(Priority.Last)] private static bool SkipCharUnlockEpochIfUnsupported(ProgressSaveManager __instance, Player localPlayer, int act) { if (localPlayer.Character is not CustomCharacterModel ccm) @@ -247,6 +243,7 @@ private static bool SkipCharUnlockEpochIfUnsupported(ProgressSaveManager __insta [HarmonyPatch(typeof(ProgressSaveManager), "CheckFifteenBossesDefeatedEpoch")] [HarmonyPrefix] + [HarmonyPriority(Priority.Last)] private static bool SkipBossEpochIfUnsupported(ProgressSaveManager __instance, Player localPlayer) { if (localPlayer.Character is not CustomCharacterModel customCharacterModel) @@ -277,6 +274,7 @@ private static bool SkipBossEpochIfUnsupported(ProgressSaveManager __instance, P [HarmonyPatch(typeof(ProgressSaveManager), "CheckFifteenElitesDefeatedEpoch")] [HarmonyPrefix] + [HarmonyPriority(Priority.Last)] private static bool SkipEliteEpochIfUnsupported(ProgressSaveManager __instance, Player localPlayer) { if (localPlayer.Character is not CustomCharacterModel customCharacterModel) @@ -308,6 +306,7 @@ private static bool SkipEliteEpochIfUnsupported(ProgressSaveManager __instance, [HarmonyPatch(typeof(ProgressSaveManager), "CheckAscensionOneCompleted")] [HarmonyPrefix] + [HarmonyPriority(Priority.Last)] private static bool SkipAscensionOneEpochIfUnsupported(ProgressSaveManager __instance, SerializablePlayer serializablePlayer, SerializableRun serializableRun) { var characterModel = ModelDb.GetById(serializablePlayer.CharacterId!);