From b8df9c343501fc3a9c23818494b0252bf49daade Mon Sep 17 00:00:00 2001 From: Zamiell <5511220+Zamiell@users.noreply.github.com> Date: Sat, 30 May 2026 20:06:39 -0400 Subject: [PATCH 1/7] fix: updates to work on latest version of the game --- .gitignore | 1 + RunReplays/Commands/TakeCardCommand.cs | 37 ++++++++++-------- RunReplays/Commands/UsePotionCommand.cs | 6 +-- .../Patches/Record/TakeCardRecordPatch.cs | 21 +++++++--- .../Patches/Replay/CardPlayReplayPatch.cs | 27 ++++++++++++- RunReplays/ReplayDispatcher.cs | 2 +- RunReplays/RunReplays.csproj | 6 +-- build.sh | 39 +++++++++++++++++++ 8 files changed, 109 insertions(+), 30 deletions(-) create mode 100644 build.sh diff --git a/.gitignore b/.gitignore index f6fce0f..b91e518 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /.idea RunReplays.sln.DotSettings.user /RunReplays/.claude +/out diff --git a/RunReplays/Commands/TakeCardCommand.cs b/RunReplays/Commands/TakeCardCommand.cs index 448dad3..5bf51ed 100644 --- a/RunReplays/Commands/TakeCardCommand.cs +++ b/RunReplays/Commands/TakeCardCommand.cs @@ -116,15 +116,17 @@ private ExecuteResult ExecuteSkip(NCardRewardSelectionScreen screen) var extras = ExtraOptionsField?.GetValue(screen) as IReadOnlyList; - // Find the skip option (AfterSelected == DismissScreenAndKeepReward). + // Find the skip option (AfterSelected == EndSelectionAndDoNotCompleteReward). CardRewardAlternative? skipAlt = null; + int skipIndex = -1; if (extras != null) { - foreach (var alt in extras) + for (int i = 0; i < extras.Count; i++) { - if (alt.AfterSelected == MegaCrit.Sts2.Core.Entities.Rewards.PostAlternateCardRewardAction.DismissScreenAndKeepReward) + if (extras[i].AfterSelected == MegaCrit.Sts2.Core.Entities.Rewards.PostAlternateCardRewardAction.EndSelectionAndDoNotCompleteReward) { - skipAlt = alt; + skipAlt = extras[i]; + skipIndex = i; break; } } @@ -133,15 +135,12 @@ private ExecuteResult ExecuteSkip(NCardRewardSelectionScreen screen) if (skipAlt != null) { TaskHelper.RunSafely(skipAlt.OnSelect()); - OnAlternateRewardSelectedMethod?.Invoke(screen, new object[] { skipAlt.AfterSelected }); + OnAlternateRewardSelectedMethod?.Invoke(screen, new object[] { skipIndex }); } else { - // Fallback: dismiss with KeepReward directly. - OnAlternateRewardSelectedMethod?.Invoke(screen, new object[] - { - MegaCrit.Sts2.Core.Entities.Rewards.PostAlternateCardRewardAction.DismissScreenAndKeepReward - }); + // Fallback: use index 0. + OnAlternateRewardSelectedMethod?.Invoke(screen, new object[] { 0 }); } ReplayState.CardRewardSelectionScreen = null; @@ -162,19 +161,25 @@ private ExecuteResult ExecuteSacrifice(NCardRewardSelectionScreen screen) } CardRewardAlternative? sacrifice = null; - foreach (var alt in extras) + int sacrificeIndex = -1; + for (int i = 0; i < extras.Count; i++) { - if (alt.OptionId.Contains("sacrifice", System.StringComparison.OrdinalIgnoreCase) - || alt.OptionId.Contains("pael", System.StringComparison.OrdinalIgnoreCase)) + if (extras[i].OptionId.Contains("sacrifice", System.StringComparison.OrdinalIgnoreCase) + || extras[i].OptionId.Contains("pael", System.StringComparison.OrdinalIgnoreCase)) { - sacrifice = alt; + sacrifice = extras[i]; + sacrificeIndex = i; break; } } - sacrifice ??= extras[0]; + if (sacrifice == null) + { + sacrifice = extras[0]; + sacrificeIndex = 0; + } TaskHelper.RunSafely(sacrifice.OnSelect()); - OnAlternateRewardSelectedMethod?.Invoke(screen, new object[] { sacrifice.AfterSelected }); + OnAlternateRewardSelectedMethod?.Invoke(screen, new object[] { sacrificeIndex }); ReplayState.CardRewardSelectionScreen = null; ReplayDispatcher.DispatchNow(); diff --git a/RunReplays/Commands/UsePotionCommand.cs b/RunReplays/Commands/UsePotionCommand.cs index 20314d7..6c95f4e 100644 --- a/RunReplays/Commands/UsePotionCommand.cs +++ b/RunReplays/Commands/UsePotionCommand.cs @@ -1,4 +1,5 @@ using System; +using MegaCrit.Sts2.Core.Combat; using MegaCrit.Sts2.Core.Entities.Creatures; using MegaCrit.Sts2.Core.Entities.Players; using MegaCrit.Sts2.Core.Models; @@ -40,8 +41,7 @@ public override string Describe() public override ExecuteResult Execute() { // Wait until the game is in the play phase before using a combat potion. - var combat = MegaCrit.Sts2.Core.Combat.CombatManager.Instance; - if (combat != null && !combat.IsPlayPhase) + if (!CardPlayReplayPatch.IsCombatPlayPhase()) return ExecuteResult.Retry(200); Player? player = CardPlayReplayPatch.ResolveLocalPlayer(); @@ -70,7 +70,7 @@ public override ExecuteResult Execute() try { - if (combat != null) + if (CombatManager.Instance != null) ReplayState.PotionInFlight = true; potion.EnqueueManualUse(target); diff --git a/RunReplays/Patches/Record/TakeCardRecordPatch.cs b/RunReplays/Patches/Record/TakeCardRecordPatch.cs index 9fb4188..3fcc649 100644 --- a/RunReplays/Patches/Record/TakeCardRecordPatch.cs +++ b/RunReplays/Patches/Record/TakeCardRecordPatch.cs @@ -1,6 +1,8 @@ +using System.Collections.Generic; using System.Reflection; using Godot; using HarmonyLib; +using MegaCrit.Sts2.Core.Entities.CardRewardAlternatives; using MegaCrit.Sts2.Core.Entities.Rewards; using MegaCrit.Sts2.Core.Models; using MegaCrit.Sts2.Core.Nodes.Screens.CardSelection; @@ -19,6 +21,10 @@ public static class TakeCardRecordPatch typeof(NCardRewardSelectionScreen).GetField( "_cardRow", BindingFlags.NonPublic | BindingFlags.Instance); + private static readonly FieldInfo? ExtraOptionsField = + typeof(NCardRewardSelectionScreen).GetField( + "_extraOptions", BindingFlags.NonPublic | BindingFlags.Instance); + /// /// Fired when the player clicks a card holder to take it. /// Records "TakeCard {index} # {cardTitle}". @@ -66,22 +72,27 @@ public static void SelectCardPrefix(NCardRewardSelectionScreen __instance, objec /// /// Fired when the player selects an alternate reward option. - /// Sacrifice (DismissScreenAndRemoveReward) records "TakeCard sacrifice". - /// Skip (DismissScreenAndKeepReward) records "TakeCard skip". + /// Sacrifice (EndSelectionAndCompleteReward) records "TakeCard sacrifice". + /// Skip (EndSelectionAndDoNotCompleteReward) records "TakeCard skip". /// [HarmonyPrefix] [HarmonyPatch("OnAlternateRewardSelected")] - public static void OnAlternatePrefix(PostAlternateCardRewardAction afterSelected) + public static void OnAlternatePrefix(NCardRewardSelectionScreen __instance, int index) { if (ReplayEngine.IsActive) return; - if (afterSelected == PostAlternateCardRewardAction.DismissScreenAndRemoveReward) + var extras = ExtraOptionsField?.GetValue(__instance) as IReadOnlyList; + if (extras == null || index < 0 || index >= extras.Count) return; + + var afterSelected = extras[index].AfterSelected; + + if (afterSelected == PostAlternateCardRewardAction.EndSelectionAndCompleteReward) { var cmd = TakeCardCommand.Sacrifice(); cmd.Comment = "sacrifice"; PlayerActionBuffer.Record(cmd.ToLogString()); } - else if (afterSelected == PostAlternateCardRewardAction.DismissScreenAndKeepReward) + else if (afterSelected == PostAlternateCardRewardAction.EndSelectionAndDoNotCompleteReward) { var cmd = TakeCardCommand.Skip(); cmd.Comment = "skip"; diff --git a/RunReplays/Patches/Replay/CardPlayReplayPatch.cs b/RunReplays/Patches/Replay/CardPlayReplayPatch.cs index 8b5d085..4ab43c7 100644 --- a/RunReplays/Patches/Replay/CardPlayReplayPatch.cs +++ b/RunReplays/Patches/Replay/CardPlayReplayPatch.cs @@ -194,6 +194,29 @@ internal static void InvalidateStaleTimers() return player; } + // STS2 v0.104.0 removed CombatManager.IsPlayPhase. + internal static bool IsCombatPlayPhase(Player? player = null) + { + try + { + player ??= ResolveLocalPlayer(); + var combatState = CombatManager.Instance.DebugOnlyGetState(); + bool playerTurn = player != null + ? CombatManager.Instance.IsPartOfPlayerTurn(player) + : combatState?.CurrentSide.ToString().Equals("Player", StringComparison.OrdinalIgnoreCase) == true; + + return CombatManager.Instance.IsInProgress + && playerTurn + && !CombatManager.Instance.EndingPlayerTurnPhaseOne + && !CombatManager.Instance.EndingPlayerTurnPhaseTwo + && !CombatManager.Instance.PlayerActionsDisabled; + } + catch + { + return false; + } + } + /// /// Returns true when combat is in progress, a local player exists, and /// the player's hand has been drawn (i.e. cards are available). @@ -206,7 +229,7 @@ internal static bool IsCombatReady() return false; // Cards can't be played while the game is drawing cards. - if (!CombatManager.Instance.IsPlayPhase) + if (!IsCombatPlayPhase()) return false; var state = CombatManager.Instance.DebugOnlyGetState(); @@ -492,7 +515,7 @@ internal static bool TryEndTurn() } // Wait until combat is in progress, in the play phase, and a player is available. - if (!CombatManager.Instance.IsInProgress || !CombatManager.Instance.IsPlayPhase || ResolveLocalPlayer() == null) + if (!IsCombatPlayPhase() || ResolveLocalPlayer() == null) { return false; } diff --git a/RunReplays/ReplayDispatcher.cs b/RunReplays/ReplayDispatcher.cs index 1b82a2a..fb23b63 100644 --- a/RunReplays/ReplayDispatcher.cs +++ b/RunReplays/ReplayDispatcher.cs @@ -456,7 +456,7 @@ private static HashSet GetDispatchableTypes() && GodotObject.IsInstanceValid(CrystalSphereReplayPatch.ActiveScreen)) types.Add(typeof(CrystalSphereClickCommand)); - if (CombatManager.Instance.IsInProgress && CombatManager.Instance.IsPlayPhase) + if (CardPlayReplayPatch.IsCombatPlayPhase()) { types.Add(typeof(PlayCardCommand)); types.Add(typeof(EndTurnCommand)); diff --git a/RunReplays/RunReplays.csproj b/RunReplays/RunReplays.csproj index 6dd8afb..4f60783 100644 --- a/RunReplays/RunReplays.csproj +++ b/RunReplays/RunReplays.csproj @@ -10,10 +10,10 @@ - ..\..\..\..\..\Program Files (x86)\Steam\steamapps\common\Slay the Spire 2\data_sts2_windows_x86_64\sts2.dll + $(STS2GameDir)\data_sts2_windows_x86_64\sts2.dll - ..\..\..\..\..\Program Files (x86)\Steam\steamapps\common\Slay the Spire 2\mods\BaseLib.dll + $(STS2GameDir)\mods\BaseLib.dll false @@ -35,7 +35,7 @@ - + diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..43c519a --- /dev/null +++ b/build.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONFIGURATION="${CONFIGURATION:-Release}" +GAME_DIR="${STS2_GAME_DIR:-C:/Program Files (x86)/Steam/steamapps/common/Slay the Spire 2}" +MODS_DIR="${STS2_MODS_DIR:-$GAME_DIR/mods}" +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +PROJECT="$SCRIPT_DIR/RunReplays/RunReplays.csproj" +OUT_DIR="$SCRIPT_DIR/out/RunReplays" + +if ! command -v dotnet >/dev/null 2>&1; then + echo "ERROR: 'dotnet' not found. Install the .NET 9 SDK:" >&2 + echo " https://dotnet.microsoft.com/download/dotnet/9.0" >&2 + exit 1 +fi + +if [[ ! -f "$GAME_DIR/data_sts2_windows_x86_64/sts2.dll" ]]; then + echo "ERROR: Could not find sts2.dll under:" >&2 + echo " $GAME_DIR/data_sts2_windows_x86_64" >&2 + echo "Set STS2_GAME_DIR to your Slay the Spire 2 install path if it differs." >&2 + exit 1 +fi + +echo "=== Building RunReplays ($CONFIGURATION) ===" +echo "Game directory : $GAME_DIR" +echo "Output : $OUT_DIR" +echo "Mods directory : $MODS_DIR" +echo + +dotnet build "$PROJECT" -c "$CONFIGURATION" -o "$OUT_DIR" -p:STS2GameDir="$GAME_DIR" + +mkdir -p "$MODS_DIR" +cp "$OUT_DIR/RunReplays.dll" "$MODS_DIR/RunReplays.dll" +cp "$SCRIPT_DIR/RunReplays.json" "$MODS_DIR/RunReplays.json" + +echo +echo "=== Installed RunReplays ===" +echo " $MODS_DIR/RunReplays.dll" +echo " $MODS_DIR/RunReplays.json" From 3af718c1b2c7bdb26f29eb3639cc033c648c75d0 Mon Sep 17 00:00:00 2001 From: Zamiell <5511220+Zamiell@users.noreply.github.com> Date: Sat, 30 May 2026 20:09:22 -0400 Subject: [PATCH 2/7] fix: warnings --- RunReplays/ReplayDispatcher.cs | 2 +- RunReplays/RunReplayMenu.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/RunReplays/ReplayDispatcher.cs b/RunReplays/ReplayDispatcher.cs index fb23b63..93b2226 100644 --- a/RunReplays/ReplayDispatcher.cs +++ b/RunReplays/ReplayDispatcher.cs @@ -318,7 +318,7 @@ public static List GetAvailableCommands() if (combatState != null) { var p = combatState.Players.FirstOrDefault(); - if (p != null) + if (p?.PlayerCombatState != null) for (int i = 0; i < p.PlayerCombatState.Hand.Cards.Count; i++) commands.Add(new SelectHandCardsCommand(new[] { i })); } diff --git a/RunReplays/RunReplayMenu.cs b/RunReplays/RunReplayMenu.cs index 50371be..702ad7d 100644 --- a/RunReplays/RunReplayMenu.cs +++ b/RunReplays/RunReplayMenu.cs @@ -565,7 +565,7 @@ private static async Task LoadSaveAsync(ReplayEntry entry) $"gameMode={serializableRun.GameMode} ascension={serializableRun.Ascension} " + $"character={serializableRun.Players?.FirstOrDefault()?.CharacterId?.Entry}"); RunState runState = RunState.FromSerializable(serializableRun); - RunManager.Instance.SetUpSavedSinglePlayer(runState, serializableRun); + await RunManager.Instance.SetUpSavedSinglePlayer(runState, serializableRun); NAudioManager.Instance?.StopMusic(); SfxCmd.Play(runState.Players[0].Character.CharacterTransitionSfx); @@ -695,7 +695,7 @@ private static async Task LoadSaveAndReplayAsync(ReplayEntry startFrom) $"gameMode={serializableRun.GameMode} ascension={serializableRun.Ascension} " + $"character={serializableRun.Players?.FirstOrDefault()?.CharacterId?.Entry}"); RunState runState = RunState.FromSerializable(serializableRun); - RunManager.Instance.SetUpSavedSinglePlayer(runState, serializableRun); + await RunManager.Instance.SetUpSavedSinglePlayer(runState, serializableRun); NAudioManager.Instance?.StopMusic(); SfxCmd.Play(runState.Players[0].Character.CharacterTransitionSfx); From 24189c7dbd29f09f31fd58b638b8c2832c766d74 Mon Sep 17 00:00:00 2001 From: Zamiell <5511220+Zamiell@users.noreply.github.com> Date: Sat, 30 May 2026 20:18:30 -0400 Subject: [PATCH 3/7] fix: manifest for new version --- RunReplays.json | 2 +- RunReplays/RunReplays.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/RunReplays.json b/RunReplays.json index 94fef26..34a3502 100644 --- a/RunReplays.json +++ b/RunReplays.json @@ -6,6 +6,6 @@ "version": "v0.2.0", "has_pck": false, "has_dll": true, - "dependencies": ["BaseLib"], + "dependencies": [{ "id": "BaseLib", "min_version": "3.1.8" }], "affects_gameplay": true } diff --git a/RunReplays/RunReplays.json b/RunReplays/RunReplays.json index 6026111..0099c83 100644 --- a/RunReplays/RunReplays.json +++ b/RunReplays/RunReplays.json @@ -6,6 +6,6 @@ "version": "v0.1.2", "has_pck": true, "has_dll": true, - "dependencies": ["BaseLib"], + "dependencies": [{ "id": "BaseLib", "min_version": "3.1.8" }], "affects_gameplay": true } From 7268247e052da69c608bf2d140f3103d07ce72dd Mon Sep 17 00:00:00 2001 From: Zamiell <5511220+Zamiell@users.noreply.github.com> Date: Sat, 30 May 2026 22:43:08 -0400 Subject: [PATCH 4/7] feat: runreplay wrapper --- RunReplays/RunReplayMenu.cs | 45 ++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/RunReplays/RunReplayMenu.cs b/RunReplays/RunReplayMenu.cs index 702ad7d..c7fbc53 100644 --- a/RunReplays/RunReplayMenu.cs +++ b/RunReplays/RunReplayMenu.cs @@ -28,6 +28,15 @@ namespace RunReplays; /// public static class RunReplayMenu { + public sealed record ReplayStartResult( + bool Success, + string Message, + string? Seed = null, + int? Floor = null, + string? CharacterId = null, + int? Ascension = null, + string? LogPath = null); + private record ReplayEntry( string Seed, string CharacterId, @@ -46,6 +55,11 @@ private record ReplayEntry( /// specific floor. /// internal static void AutoPlay(string target) + { + _ = StartReplayTarget(target); + } + + public static ReplayStartResult StartReplayTarget(string target) { string seed; int? targetFloor = null; @@ -59,13 +73,22 @@ internal static void AutoPlay(string target) int.TryParse(floorPart["floor_".Length..], out int f)) targetFloor = f; else - GD.PrintErr($"[RunReplays] AutoPlay: invalid floor specifier '{floorPart}', replaying highest floor."); + GD.PrintErr($"[RunReplays] StartReplayTarget: invalid floor specifier '{floorPart}', replaying highest floor."); } else { seed = target; } + return StartReplayBySeed(seed, targetFloor); + } + + public static ReplayStartResult StartReplayBySeed(string seed, int? targetFloor = null) + { + if (string.IsNullOrWhiteSpace(seed)) + return new ReplayStartResult(false, "Missing replay seed."); + + seed = seed.Trim(); var entries = LoadEntries() .Where(e => string.Equals(e.Seed, seed, StringComparison.OrdinalIgnoreCase)) .OrderByDescending(e => e.Floor) @@ -73,8 +96,9 @@ internal static void AutoPlay(string target) if (entries.Count == 0) { - GD.PrintErr($"[RunReplays] AutoPlay: no replays found for seed '{seed}'."); - return; + string message = $"No replays found for seed '{seed}'."; + GD.PrintErr($"[RunReplays] StartReplayBySeed: {message}"); + return new ReplayStartResult(false, message, Seed: seed); } ReplayEntry entry; @@ -83,8 +107,9 @@ internal static void AutoPlay(string target) entry = entries.FirstOrDefault(e => e.Floor == targetFloor.Value)!; if (entry == null) { - GD.PrintErr($"[RunReplays] AutoPlay: no replay found for seed '{seed}' floor {targetFloor.Value}."); - return; + string message = $"No replay found for seed '{seed}' floor {targetFloor.Value}."; + GD.PrintErr($"[RunReplays] StartReplayBySeed: {message}"); + return new ReplayStartResult(false, message, Seed: seed, Floor: targetFloor); } } else @@ -92,8 +117,16 @@ internal static void AutoPlay(string target) entry = entries.First(); } - GD.Print($"[RunReplays] AutoPlay: launching seed={entry.Seed} floor={entry.Floor}"); + GD.Print($"[RunReplays] StartReplayBySeed: launching seed={entry.Seed} floor={entry.Floor}"); StartReplay(entry); + return new ReplayStartResult( + true, + $"Starting replay seed={entry.Seed} floor={entry.Floor}.", + entry.Seed, + entry.Floor, + entry.CharacterId, + entry.Ascension, + entry.MinimalLogPath); } // ── List population ─────────────────────────────────────────────────────── From 91f77d347d6c19e4a4c5886735600a3c74470b13 Mon Sep 17 00:00:00 2001 From: Zamiell <5511220+Zamiell@users.noreply.github.com> Date: Sat, 30 May 2026 22:46:37 -0400 Subject: [PATCH 5/7] feat: ListReplays --- RunReplays/RunReplayMenu.cs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/RunReplays/RunReplayMenu.cs b/RunReplays/RunReplayMenu.cs index c7fbc53..06d30b0 100644 --- a/RunReplays/RunReplayMenu.cs +++ b/RunReplays/RunReplayMenu.cs @@ -37,6 +37,17 @@ public sealed record ReplayStartResult( int? Ascension = null, string? LogPath = null); + public sealed record ReplayListEntry( + string Seed, + string CharacterId, + int Floor, + int Ascension, + DateTime SavedAt, + string MinimalLogPath, + string? SavePath, + bool IsSample, + string Target); + private record ReplayEntry( string Seed, string CharacterId, @@ -129,6 +140,22 @@ public static ReplayStartResult StartReplayBySeed(string seed, int? targetFloor entry.MinimalLogPath); } + public static IReadOnlyList ListReplays() + { + return LoadEntries() + .Select(entry => new ReplayListEntry( + entry.Seed, + entry.CharacterId, + entry.Floor, + entry.Ascension, + entry.SavedAt, + entry.MinimalLogPath, + entry.SavePath, + entry.IsSample, + $"{entry.Seed}:floor_{entry.Floor}")) + .ToList(); + } + // ── List population ─────────────────────────────────────────────────────── internal static void PopulateList(VBoxContainer list, Action closeMenu) From 1525e189d546e16018cccc7033d49819a0f1f8e0 Mon Sep 17 00:00:00 2001 From: Zamiell <5511220+Zamiell@users.noreply.github.com> Date: Sat, 30 May 2026 23:48:59 -0400 Subject: [PATCH 6/7] fix: bugs --- RunReplays/Commands/EndTurnCommand.cs | 6 + RunReplays/Commands/PlayCardCommand.cs | 51 +++++- RunReplays/Commands/ReplayCommand.cs | 6 + RunReplays/Commands/SelectGridCardCommand.cs | 151 +++++++++++++++++- .../Patches/Replay/CardPlayReplayPatch.cs | 2 +- RunReplays/ReplayDispatcher.cs | 42 +++-- RunReplays/ReplayEngine.cs | 33 ++++ RunReplays/RunReplayMenu.cs | 47 ++++++ 8 files changed, 325 insertions(+), 13 deletions(-) diff --git a/RunReplays/Commands/EndTurnCommand.cs b/RunReplays/Commands/EndTurnCommand.cs index 48e0fe8..9e0f7ad 100644 --- a/RunReplays/Commands/EndTurnCommand.cs +++ b/RunReplays/Commands/EndTurnCommand.cs @@ -1,4 +1,5 @@ using RunReplays.Patches.Replay; +using MegaCrit.Sts2.Core.Combat; namespace RunReplays.Commands; /// @@ -17,6 +18,11 @@ public EndTurnCommand() : base("") { } public override ExecuteResult Execute() { + if (CombatManager.Instance == null + || !CombatManager.Instance.IsInProgress + || CombatManager.Instance.IsOverOrEnding) + return ExecuteResult.Ok(); + if (CardPlayReplayPatch.TryEndTurn()) return ExecuteResult.Ok(); return ExecuteResult.Retry(200); diff --git a/RunReplays/Commands/PlayCardCommand.cs b/RunReplays/Commands/PlayCardCommand.cs index 6d57988..87c1bba 100644 --- a/RunReplays/Commands/PlayCardCommand.cs +++ b/RunReplays/Commands/PlayCardCommand.cs @@ -1,3 +1,5 @@ +using System.Linq; +using Godot; using MegaCrit.Sts2.Core.Entities.Creatures; using MegaCrit.Sts2.Core.GameActions.Multiplayer; using MegaCrit.Sts2.Core.Models; @@ -56,7 +58,8 @@ public override ExecuteResult Execute() return ExecuteResult.Retry(100); } - PlayerActionBuffer.LogDispatcher("Found card to play"); + card = ResolveCurrentHandCard(card); + PlayerActionBuffer.LogDispatcher($"Found card to play: {card.Id.Entry}"); Creature? target = null; if (TargetId.HasValue) @@ -75,6 +78,52 @@ public override ExecuteResult Execute() return ExecuteResult.Retry(100); } + private CardModel ResolveCurrentHandCard(CardModel resolved) + { + var hand = CardPlayReplayPatch.ResolveLocalPlayer() + ?.PlayerCombatState + ?.Hand + ?.Cards; + if (hand == null) + return resolved; + + string? recordedId = RecordedCardId(); + if (recordedId != null) + { + GD.Print( + $"[RunReplays] [PlayCard] command={ToLogString()} hand=[{string.Join(", ", hand.Select(card => card.Id.Entry))}] resolved={resolved.Id.Entry}"); + var matching = hand.LastOrDefault(card => card.Id.Entry == recordedId); + if (matching != null) + { + GD.Print( + $"[RunReplays] [PlayCard] selected recorded card {recordedId} from hand for command {ToLogString()}"); + PlayerActionBuffer.LogDispatcher( + $"[RunReplays] Resolved replay combat card {CombatCardIndex} via recorded hand card {recordedId}."); + return matching; + } + } + + if (hand.Any(card => ReferenceEquals(card, resolved))) + return resolved; + + return resolved; + } + + private string? RecordedCardId() + { + if (string.IsNullOrWhiteSpace(Comment)) + return null; + + const string prefix = "CARD."; + int start = Comment.IndexOf(prefix, StringComparison.Ordinal); + if (start < 0) + return null; + + start += prefix.Length; + int end = Comment.IndexOfAny([' ', ')'], start); + return end > start ? Comment[start..end] : Comment[start..]; + } + public static PlayCardCommand? TryParse(string raw) { if (!raw.StartsWith(Prefix)) diff --git a/RunReplays/Commands/ReplayCommand.cs b/RunReplays/Commands/ReplayCommand.cs index 60b8153..0cad973 100644 --- a/RunReplays/Commands/ReplayCommand.cs +++ b/RunReplays/Commands/ReplayCommand.cs @@ -46,6 +46,12 @@ public abstract class ReplayCommand /// public string? Comment { get; set; } + /// + /// Optional state snapshot suffix from minimal replay entries, delimited by + /// " || ". Commands can use this to recover recorder context. + /// + public string? StateSuffix { get; set; } + protected ReplayCommand(string rawText) { RawText = rawText; diff --git a/RunReplays/Commands/SelectGridCardCommand.cs b/RunReplays/Commands/SelectGridCardCommand.cs index b4889db..cbd0261 100644 --- a/RunReplays/Commands/SelectGridCardCommand.cs +++ b/RunReplays/Commands/SelectGridCardCommand.cs @@ -1,5 +1,10 @@ using System.Collections.Generic; +using System.Linq; +using Godot; using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.Nodes.Screens.CardSelection; +using MegaCrit.Sts2.Core.Nodes.Screens.Overlays; +using RunReplays.Patches.Replay; namespace RunReplays.Commands; @@ -32,15 +37,37 @@ public override string Describe() public override ExecuteResult Execute() { - var screen = CardGridScreenCapture.ActiveScreen; + var screen = CardGridScreenCapture.ActiveScreen + ?? NOverlayStack.Instance?.Peek() as NCardGridSelectionScreen; if (screen == null) + { + GD.Print("[RunReplays] [SelectGridCard] Retry: no active grid screen."); + PlayerActionBuffer.LogDispatcher("[SelectGridCard] Retry: no active grid screen."); return ExecuteResult.Retry(300); + } - var cards = CardGridScreenCapture.CardsField?.GetValue(screen) as IReadOnlyList; + var cards = GetSelectableCards(screen); if (cards == null) + { + GD.Print($"[RunReplays] [SelectGridCard] Retry: could not read _cards from {screen.GetType().Name}."); + PlayerActionBuffer.LogDispatcher( + $"[SelectGridCard] Retry: could not read _cards from {screen.GetType().Name}."); return ExecuteResult.Retry(300); + } var selected = new List(); + if (Indices.All(idx => idx < 0)) + { + if (!TryInferNegativeSelection(cards, out int inferredIndex)) + return ExecuteResult.Retry(300); + + CardGridScreenCapture.ClickCard(screen, cards[inferredIndex]); + selected.Add(cards[inferredIndex]); + CardGridScreenCapture.ConfirmSelection(screen, selected); + CardGridScreenCapture.ActiveScreen = null; + return ExecuteResult.Ok(); + } + foreach (int idx in Indices) { if (idx < 0 || idx >= cards.Count) @@ -55,6 +82,126 @@ public override ExecuteResult Execute() return ExecuteResult.Ok(); } + private static bool TryInferNegativeSelection( + IReadOnlyList cards, + out int inferredIndex) + { + inferredIndex = -1; + + RunReplays.ReplayEngine.GetReplayContext( + out _, + out _, + out IReadOnlyList next); + string? nextHand = next + .Select(command => ExtractHandSnapshot(command.StateSuffix)) + .FirstOrDefault(hand => !string.IsNullOrWhiteSpace(hand)); + if (string.IsNullOrWhiteSpace(nextHand)) + { + GD.Print("[RunReplays] [SelectGridCard] Could not infer -1 selection: no next hand snapshot."); + PlayerActionBuffer.LogDispatcher("[SelectGridCard] Could not infer -1 selection: no next hand snapshot."); + return false; + } + + GD.Print( + $"[RunReplays] [SelectGridCard] Inferring -1 selection from next hand [{nextHand}] and cards [{string.Join(", ", cards.Select(card => card.Title))}]."); + PlayerActionBuffer.LogDispatcher( + $"[SelectGridCard] Inferring -1 selection from next hand [{nextHand}] and cards [{string.Join(", ", cards.Select(card => card.Title))}]."); + + var currentHandTitles = CardPlayReplayPatch.ResolveLocalPlayer() + ?.PlayerCombatState + ?.Hand + ?.Cards + .Select(card => card.Title) + .ToList() ?? new List(); + + for (int i = 0; i < cards.Count; i++) + { + string title = cards[i].Title; + string normalizedTitle = NormalizeCardTitle(title); + if (CountTitle(nextHand, title) > currentHandTitles.Count(handTitle => + NormalizeCardTitle(handTitle) == normalizedTitle)) + { + GD.Print($"[RunReplays] [SelectGridCard] Inferred -1 selection by hand delta as index {i} ({title})."); + inferredIndex = i; + return true; + } + } + + for (int i = 0; i < cards.Count; i++) + { + string title = cards[i].Title; + string normalizedTitle = NormalizeCardTitle(title); + string normalizedHand = NormalizeCardTitle(nextHand); + if (nextHand.Contains(title, System.StringComparison.OrdinalIgnoreCase) + || normalizedHand.Contains(normalizedTitle, System.StringComparison.OrdinalIgnoreCase)) + { + GD.Print($"[RunReplays] [SelectGridCard] Inferred -1 selection as index {i} ({title})."); + PlayerActionBuffer.LogDispatcher( + $"[SelectGridCard] Inferred -1 selection as index {i} ({title})."); + inferredIndex = i; + return true; + } + } + + GD.Print("[RunReplays] [SelectGridCard] Could not infer -1 selection: no card title matched."); + PlayerActionBuffer.LogDispatcher("[SelectGridCard] Could not infer -1 selection: no card title matched."); + return false; + } + + private static string NormalizeCardTitle(string value) + => new(value + .Where(ch => char.IsLetterOrDigit(ch)) + .Select(char.ToUpperInvariant) + .ToArray()); + + private static int CountTitle(string handSnapshot, string title) + { + string normalizedTitle = NormalizeCardTitle(title); + return handSnapshot + .Split(',') + .Select(part => NormalizeCardTitle(part.Trim())) + .Count(part => part == normalizedTitle); + } + + private static string? ExtractHandSnapshot(string? stateSuffix) + { + if (string.IsNullOrWhiteSpace(stateSuffix)) + return null; + + const string handPrefix = "Hand: ["; + int start = stateSuffix.IndexOf(handPrefix, System.StringComparison.Ordinal); + if (start < 0) + return null; + + start += handPrefix.Length; + int end = stateSuffix.IndexOf(']', start); + if (end < 0) + return null; + + return stateSuffix[start..end]; + } + + private static IReadOnlyList? GetSelectableCards(NCardGridSelectionScreen screen) + { + if (CardGridScreenCapture.CardsField?.GetValue(screen) is IReadOnlyList cards + && cards.Count > 0) + return cards; + + var holderCards = new List(); + foreach (Node node in screen.FindChildren("*", "", owned: false)) + { + var prop = node.GetType().GetProperty( + "CardModel", + System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.NonPublic + | System.Reflection.BindingFlags.Instance); + if (prop?.GetValue(node) is CardModel card) + holderCards.Add(card); + } + + return holderCards; + } + public static SelectGridCardCommand? TryParse(string raw) { if (!raw.StartsWith(Prefix)) diff --git a/RunReplays/Patches/Replay/CardPlayReplayPatch.cs b/RunReplays/Patches/Replay/CardPlayReplayPatch.cs index 4ab43c7..5c77ca8 100644 --- a/RunReplays/Patches/Replay/CardPlayReplayPatch.cs +++ b/RunReplays/Patches/Replay/CardPlayReplayPatch.cs @@ -368,7 +368,7 @@ private static void TryCompleteEndTurnGate() /// before dispatching the next command. Extra frames allow enemy /// animations (damage numbers, status effects, deaths) to finish. /// - private const int QuietFramesRequired = 3; + private const int QuietFramesRequired = 12; diff --git a/RunReplays/ReplayDispatcher.cs b/RunReplays/ReplayDispatcher.cs index 93b2226..15f5154 100644 --- a/RunReplays/ReplayDispatcher.cs +++ b/RunReplays/ReplayDispatcher.cs @@ -9,6 +9,7 @@ using MegaCrit.Sts2.Core.Nodes; using MegaCrit.Sts2.Core.Nodes.Screens.CardSelection; using MegaCrit.Sts2.Core.Nodes.Screens.Map; +using MegaCrit.Sts2.Core.Nodes.Screens.Overlays; using MegaCrit.Sts2.Core.Rooms; using MegaCrit.Sts2.Core.Runs; using RunReplays.Commands; @@ -40,6 +41,23 @@ public static class ReplayDispatcher return state?.CurrentRoom; } + private static NCardGridSelectionScreen? ActiveCardGridSelectionScreen() + { + var captured = CardGridScreenCapture.ActiveScreen; + if (captured != null + && GodotObject.IsInstanceValid(captured) + && captured.IsInsideTree()) + return captured; + + var overlay = NOverlayStack.Instance?.Peek() as NCardGridSelectionScreen; + if (overlay != null + && GodotObject.IsInstanceValid(overlay) + && overlay.IsInsideTree()) + return overlay; + + return null; + } + private static bool LocalPlayerHasPotions() { var state = RunStateProp?.GetValue(RunManager.Instance) as IRunState; @@ -260,7 +278,7 @@ public static List GetAvailableCommands() // -- selection screens -- else if (type == typeof(SelectGridCardCommand)) { - var screen = CardGridScreenCapture.ActiveScreen; + var screen = ActiveCardGridSelectionScreen(); if (screen != null) { var cards = CardGridScreenCapture.CardsField?.GetValue(screen) @@ -272,7 +290,7 @@ public static List GetAvailableCommands() } else if (type == typeof(ClickGridCardCommand)) { - var screen = CardGridScreenCapture.ActiveScreen; + var screen = ActiveCardGridSelectionScreen(); if (screen != null) { var cards = CardGridScreenCapture.CardsField?.GetValue(screen) @@ -402,10 +420,10 @@ private static HashSet GetDispatchableTypes() var types = new HashSet(); + if (ReplayEngine.PeekNext(out ReplayCommand? nextCommand) && nextCommand?.IsSelectionCommand == true) + types.Add(nextCommand.GetType()); - if (CardGridScreenCapture.ActiveScreen != null - && GodotObject.IsInstanceValid(CardGridScreenCapture.ActiveScreen) - && CardGridScreenCapture.ActiveScreen.IsInsideTree()) + if (ActiveCardGridSelectionScreen() != null) { types.Add(typeof(SelectGridCardCommand)); types.Add(typeof(ClickGridCardCommand)); @@ -434,9 +452,7 @@ private static HashSet GetDispatchableTypes() types.Add(typeof(DiscardPotionCommand)); } - if (CardGridScreenCapture.ActiveScreen != null - && GodotObject.IsInstanceValid(CardGridScreenCapture.ActiveScreen) - && CardGridScreenCapture.ActiveScreen.IsInsideTree()) + if (ActiveCardGridSelectionScreen() != null) { types.Add(typeof(SelectGridCardCommand)); types.Add(typeof(ClickGridCardCommand)); @@ -987,7 +1003,14 @@ private static void ExecuteNext() if (!ReplayEngine.PeekNext(out ReplayCommand? cmd) || cmd == null) return; - if (!GetDispatchableTypes().Contains(cmd.GetType())) + bool terminalEndTurn = cmd is EndTurnCommand + && (CombatManager.Instance == null + || !CombatManager.Instance.IsInProgress + || CombatManager.Instance.IsOverOrEnding); + + if (!terminalEndTurn + && !cmd.IsSelectionCommand + && !GetDispatchableTypes().Contains(cmd.GetType())) { _dispatchInProgress = false; _lastDispatchedCmd = null; @@ -997,6 +1020,7 @@ private static void ExecuteNext() _lastDispatchTick = System.Environment.TickCount64; + GD.Print($"[RunReplays] ExecuteNext dispatching {cmd.GetType().Name} selection={cmd.IsSelectionCommand}"); DiagnosticLog.Write("Dispatch", $"execute → {cmd.GetType().Name}({cmd})"); ExecuteResult result; try diff --git a/RunReplays/ReplayEngine.cs b/RunReplays/ReplayEngine.cs index a5d8064..bb645a7 100644 --- a/RunReplays/ReplayEngine.cs +++ b/RunReplays/ReplayEngine.cs @@ -14,6 +14,19 @@ namespace RunReplays; /// public static class ReplayEngine { + public sealed record ReplayStatus( + bool IsActive, + bool IsReplayRun, + string? ActiveSeed, + int LoadedCount, + int PendingCount, + int ConsumedCount, + string? CurrentCommand, + string? CurrentStateSuffix, + IReadOnlyList NextCommands, + IReadOnlyList NextStateSuffixes, + IReadOnlyList RecentConsumed); + internal static readonly Queue _pending = new(); // ── Overlay context ─────────────────────────────────────────────────────── @@ -108,6 +121,24 @@ internal static void GetReplayContext( public static string? ActiveSeed { get; set; } + public static ReplayStatus GetStatus() + { + int pendingCount = _pending.Count; + ReplayCommand[] pending = _pending.ToArray(); + return new ReplayStatus( + IsActive, + IsReplayRun, + ActiveSeed, + _loadedCommands.Count, + pendingCount, + Math.Max(0, _loadedCommands.Count - pendingCount), + pending.Length > 0 ? pending[0].ToLogString() : null, + pending.Length > 0 ? pending[0].StateSuffix : null, + pending.Skip(1).Take(3).Select(command => command.ToLogString()).ToList(), + pending.Skip(1).Take(3).Select(command => command.StateSuffix).ToList(), + _recentConsumed.Select(command => command.ToLogString()).ToList()); + } + /// State suffix separator embedded in minimal log entries. private const string StateSeparator = " || "; @@ -129,6 +160,7 @@ public static void Load(IReadOnlyList commands) int sepIdx = raw.IndexOf(StateSeparator, StringComparison.Ordinal); string cmdText = sepIdx >= 0 ? raw[..sepIdx] : raw; + string? stateSuffix = sepIdx >= 0 ? raw[(sepIdx + StateSeparator.Length)..] : null; // Strip inline comment: "CommandText # comment" string? comment = null; @@ -148,6 +180,7 @@ public static void Load(IReadOnlyList commands) } parsed.Comment = comment; + parsed.StateSuffix = stateSuffix; _loadedCommands.Add(parsed); _pending.Enqueue(parsed); } diff --git a/RunReplays/RunReplayMenu.cs b/RunReplays/RunReplayMenu.cs index 06d30b0..05b7c08 100644 --- a/RunReplays/RunReplayMenu.cs +++ b/RunReplays/RunReplayMenu.cs @@ -140,6 +140,53 @@ public static ReplayStartResult StartReplayBySeed(string seed, int? targetFloor entry.MinimalLogPath); } + public static ReplayStartResult StartReplayFromFloorBySeed( + string seed, + int targetFloor, + int startFloor) + { + if (string.IsNullOrWhiteSpace(seed)) + return new ReplayStartResult(false, "Missing replay seed."); + + seed = seed.Trim(); + var entries = LoadEntries() + .Where(e => string.Equals(e.Seed, seed, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + ReplayEntry? target = entries.FirstOrDefault(e => e.Floor == targetFloor); + if (target == null) + return new ReplayStartResult( + false, + $"No replay found for seed '{seed}' floor {targetFloor}.", + Seed: seed, + Floor: targetFloor); + + ReplayEntry? startFrom = entries.FirstOrDefault(e => e.Floor == startFloor); + if (startFrom == null) + return new ReplayStartResult( + false, + $"No starting save found for seed '{seed}' floor {startFloor}.", + Seed: seed, + Floor: targetFloor); + + if (startFrom.SavePath == null) + return new ReplayStartResult( + false, + $"Replay seed '{seed}' floor {startFloor} has no run.save.", + Seed: seed, + Floor: targetFloor); + + StartReplayFromFloor(target, startFrom); + return new ReplayStartResult( + true, + $"Starting replay seed={target.Seed} from floor {startFrom.Floor} to floor {target.Floor}.", + target.Seed, + target.Floor, + target.CharacterId, + target.Ascension, + target.MinimalLogPath); + } + public static IReadOnlyList ListReplays() { return LoadEntries() From 0248220d04882f576b29b3de92908b1d29be03e0 Mon Sep 17 00:00:00 2001 From: Zamiell <5511220+Zamiell@users.noreply.github.com> Date: Sun, 31 May 2026 02:50:26 -0400 Subject: [PATCH 7/7] fix: replay --- RunReplays/Commands/ClaimRewardCommand.cs | 38 +++++- RunReplays/Commands/PlayCardCommand.cs | 6 +- RunReplays/Commands/ShopCommands.cs | 112 ++++++++++++------ RunReplays/Commands/UsePotionCommand.cs | 15 ++- .../Patches/Replay/CardRewardReplayPatch.cs | 3 +- RunReplays/ReplayDispatcher.cs | 25 +++- 6 files changed, 150 insertions(+), 49 deletions(-) diff --git a/RunReplays/Commands/ClaimRewardCommand.cs b/RunReplays/Commands/ClaimRewardCommand.cs index d8287a1..a7819c5 100644 --- a/RunReplays/Commands/ClaimRewardCommand.cs +++ b/RunReplays/Commands/ClaimRewardCommand.cs @@ -49,20 +49,52 @@ public override ExecuteResult Execute() return ExecuteResult.Retry(200); var buttons = EnumerateRewardButtons(screen).ToList(); - if (RewardIndex < 0 || RewardIndex >= buttons.Count) + int index = ResolveRewardIndex(buttons); + if (index < 0 || index >= buttons.Count) { PlayerActionBuffer.LogMigrationWarning( $"[ClaimReward] Index {RewardIndex} out of range (count={buttons.Count}) — retrying."); return ExecuteResult.Retry(200); } - var (button, reward) = buttons[RewardIndex]; + var (button, reward) = buttons[index]; InvokeGetReward(button); PlayerActionBuffer.LogDispatcher( - $"[ClaimReward] Claimed reward [{RewardIndex}] ({reward.GetType().Name})."); + $"[ClaimReward] Claimed reward [{index}] ({reward.GetType().Name})."); return ExecuteResult.Ok(); } + private int ResolveRewardIndex(IReadOnlyList<(Node button, object reward)> buttons) + { + string? expectedType = ExpectedRewardType(); + if (expectedType == null) + return RewardIndex; + + for (int i = 0; i < buttons.Count; i++) + { + if (IsRewardOfType(buttons[i].reward, expectedType)) + { + if (i != RewardIndex) + { + PlayerActionBuffer.LogMigrationWarning( + $"[ClaimReward] Remapped {Comment} from index {RewardIndex} to {i}."); + } + return i; + } + } + + return RewardIndex; + } + + private string? ExpectedRewardType() + { + if (string.IsNullOrWhiteSpace(Comment)) + return null; + + string type = Comment.Split(':', 2)[0].Trim(); + return string.IsNullOrEmpty(type) ? null : type; + } + public static ClaimRewardCommand? TryParse(string raw) { if (!raw.StartsWith(Prefix)) diff --git a/RunReplays/Commands/PlayCardCommand.cs b/RunReplays/Commands/PlayCardCommand.cs index 87c1bba..578aaa3 100644 --- a/RunReplays/Commands/PlayCardCommand.cs +++ b/RunReplays/Commands/PlayCardCommand.cs @@ -87,6 +87,9 @@ private CardModel ResolveCurrentHandCard(CardModel resolved) if (hand == null) return resolved; + if (hand.Any(card => ReferenceEquals(card, resolved))) + return resolved; + string? recordedId = RecordedCardId(); if (recordedId != null) { @@ -103,9 +106,6 @@ private CardModel ResolveCurrentHandCard(CardModel resolved) } } - if (hand.Any(card => ReferenceEquals(card, resolved))) - return resolved; - return resolved; } diff --git a/RunReplays/Commands/ShopCommands.cs b/RunReplays/Commands/ShopCommands.cs index 8eb8c69..bb7c971 100644 --- a/RunReplays/Commands/ShopCommands.cs +++ b/RunReplays/Commands/ShopCommands.cs @@ -5,6 +5,8 @@ using MegaCrit.Sts2.Core.Helpers; using MegaCrit.Sts2.Core.Nodes.Events.Custom; using MegaCrit.Sts2.Core.Nodes.Rooms; +using MegaCrit.Sts2.Core.Rooms; +using MegaCrit.Sts2.Core.Runs; namespace RunReplays.Commands; @@ -14,6 +16,10 @@ namespace RunReplays.Commands; /// public sealed class OpenShopCommand : ReplayCommand { + private static readonly PropertyInfo? RunStateProp = + typeof(RunManager).GetProperty("State", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + public OpenShopCommand() : base("") { } @@ -24,7 +30,7 @@ public OpenShopCommand() : base("") public override ExecuteResult Execute() { - var room = ReplayState.ActiveMerchantRoom; + var room = ResolveRoom(); if (room == null || !room.IsInsideTree()) return ExecuteResult.Retry(200); @@ -37,12 +43,23 @@ public override ExecuteResult Execute() return raw == "OpenShop" ? new OpenShopCommand() : null; } + internal static NMerchantRoom? ResolveRoom() + { + if (NMerchantRoom.Instance is { } instance) + { + ReplayState.ActiveMerchantRoom = instance; + return instance; + } + + return ReplayState.ActiveMerchantRoom; + } + /// /// Invokes OnTryPurchaseWrapper on the most-derived type of the entry, /// filling any extra parameters (e.g. MerchantCardRemovalEntry.cancelable) /// with their declared default values. /// - internal static void InvokePurchase(MerchantEntry entry) + internal static void InvokePurchase(MerchantEntry entry, MerchantInventory? inventory = null) { var method = entry.GetType() .GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) @@ -60,11 +77,13 @@ internal static void InvokePurchase(MerchantEntry entry) } var args = method.GetParameters() - .Select(p => p.HasDefaultValue - ? p.DefaultValue - : p.ParameterType.IsValueType - ? Activator.CreateInstance(p.ParameterType) - : null) + .Select(p => inventory != null && p.ParameterType.IsInstanceOfType(inventory) + ? inventory + : p.HasDefaultValue + ? p.DefaultValue + : p.ParameterType.IsValueType + ? Activator.CreateInstance(p.ParameterType) + : null) .ToArray(); var result = method.Invoke(entry, args); @@ -112,8 +131,8 @@ internal static void InvokePurchase(MerchantEntry entry) foreach (var item in enumerable) if (item is MerchantEntry e) all.Add(e); - else if (value is MerchantEntry single) - all.Add(single); + else if (value is MerchantEntry single) + all.Add(single); } foreach (var prop in inventory.GetType().GetProperties(bf)) @@ -135,8 +154,8 @@ internal static void InvokePurchase(MerchantEntry entry) foreach (var item in enumerable) if (item is MerchantEntry e && !all.Contains(e)) all.Add(e); - else if (value is MerchantEntry single && !all.Contains(single)) - all.Add(single); + else if (value is MerchantEntry single && !all.Contains(single)) + all.Add(single); } if (all.Count > 0) @@ -147,10 +166,32 @@ internal static void InvokePurchase(MerchantEntry entry) return all; } + var localInventoryEntries = GetLocalInventory()?.AllEntries?.ToList(); + if (localInventoryEntries is { Count: > 0 }) + { + PlayerActionBuffer.LogToDevConsole( + $"[ShopReplayPatch] Found {localInventoryEntries.Count} entries in MerchantRoom.GetLocalInventory " + + $"({string.Join(", ", localInventoryEntries.Select(e => e.GetType().Name))})."); + return localInventoryEntries; + } + PlayerActionBuffer.LogToDevConsole( $"[ShopReplayPatch] Inventory ({inventory.GetType().Name}) yielded no MerchantEntry objects."); return null; } + + internal static MerchantInventory? GetLocalInventory() + { + var state = RunStateProp?.GetValue(RunManager.Instance); + var currentRoom = state?.GetType() + .GetProperty("CurrentRoom", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?.GetValue(state); + + if (currentRoom is not MerchantRoom merchantRoom) + return null; + + return merchantRoom.GetLocalInventory(); + } } /// @@ -175,11 +216,10 @@ public BuyCardCommand(string cardTitle) : base("") public override ExecuteResult Execute() { - var room = ReplayState.ActiveMerchantRoom; - if (room == null || !room.IsInsideTree()) - return ExecuteResult.Retry(200); - - var entries = OpenShopCommand.GetEntries(room); + var room = OpenShopCommand.ResolveRoom(); + var entries = room != null ? OpenShopCommand.GetEntries(room) : null; + var inventory = OpenShopCommand.GetLocalInventory(); + entries ??= inventory?.AllEntries?.ToList(); if (entries == null || entries.Count == 0) return ExecuteResult.Retry(200); @@ -192,7 +232,7 @@ public override ExecuteResult Execute() return ExecuteResult.Ok(); } - OpenShopCommand.InvokePurchase(entry); + OpenShopCommand.InvokePurchase(entry, inventory); return ExecuteResult.Ok(); } @@ -239,9 +279,11 @@ public override ExecuteResult Execute() { List? entries = null; - var room = ReplayState.ActiveMerchantRoom; + var room = OpenShopCommand.ResolveRoom(); if (room != null && room.IsInsideTree()) entries = OpenShopCommand.GetEntries(room); + var inventory = OpenShopCommand.GetLocalInventory(); + entries ??= inventory?.AllEntries?.ToList(); // Fall back to fake merchant if regular shop isn't active. if ((entries == null || entries.Count == 0) && ReplayState.FakeMerchantInstance != null) @@ -259,7 +301,7 @@ public override ExecuteResult Execute() return ExecuteResult.Ok(); } - OpenShopCommand.InvokePurchase(entry); + OpenShopCommand.InvokePurchase(entry, inventory); return ExecuteResult.Ok(); } @@ -306,8 +348,8 @@ public override ExecuteResult Execute() foreach (var item in enumerable) if (item is MerchantEntry e) all.Add(e); - else if (value is MerchantEntry single) - all.Add(single); + else if (value is MerchantEntry single) + all.Add(single); } foreach (var prop in inventory.GetType().GetProperties(bf)) @@ -327,8 +369,8 @@ public override ExecuteResult Execute() foreach (var item in enumerable) if (item is MerchantEntry e && !all.Contains(e)) all.Add(e); - else if (value is MerchantEntry single && !all.Contains(single)) - all.Add(single); + else if (value is MerchantEntry single && !all.Contains(single)) + all.Add(single); } return all.Count > 0 ? all : null; @@ -354,11 +396,10 @@ public BuyCardRemovalCommand() : base("") public override ExecuteResult Execute() { - var room = ReplayState.ActiveMerchantRoom; - if (room == null || !room.IsInsideTree()) - return ExecuteResult.Retry(200); - - var entries = OpenShopCommand.GetEntries(room); + var room = OpenShopCommand.ResolveRoom(); + var entries = room != null ? OpenShopCommand.GetEntries(room) : null; + var inventory = OpenShopCommand.GetLocalInventory(); + entries ??= inventory?.AllEntries?.ToList(); if (entries == null || entries.Count == 0) return ExecuteResult.Retry(200); @@ -369,7 +410,7 @@ public override ExecuteResult Execute() return ExecuteResult.Ok(); } - OpenShopCommand.InvokePurchase(entry); + OpenShopCommand.InvokePurchase(entry, inventory); return ExecuteResult.Ok(); } @@ -401,11 +442,10 @@ public BuyPotionCommand(string potionTitle) : base("") public override ExecuteResult Execute() { - var room = ReplayState.ActiveMerchantRoom; - if (room == null || !room.IsInsideTree()) - return ExecuteResult.Retry(200); - - var entries = OpenShopCommand.GetEntries(room); + var room = OpenShopCommand.ResolveRoom(); + var entries = room != null ? OpenShopCommand.GetEntries(room) : null; + var inventory = OpenShopCommand.GetLocalInventory(); + entries ??= inventory?.AllEntries?.ToList(); if (entries == null || entries.Count == 0) return ExecuteResult.Retry(200); @@ -418,7 +458,7 @@ public override ExecuteResult Execute() return ExecuteResult.Ok(); } - OpenShopCommand.InvokePurchase(entry); + OpenShopCommand.InvokePurchase(entry, inventory); return ExecuteResult.Ok(); } @@ -428,4 +468,4 @@ public override ExecuteResult Execute() return null; return new BuyPotionCommand(raw.Substring(Prefix.Length)); } -} \ No newline at end of file +} diff --git a/RunReplays/Commands/UsePotionCommand.cs b/RunReplays/Commands/UsePotionCommand.cs index 6c95f4e..63163e3 100644 --- a/RunReplays/Commands/UsePotionCommand.cs +++ b/RunReplays/Commands/UsePotionCommand.cs @@ -1,5 +1,6 @@ using System; using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.Entities.Cards; using MegaCrit.Sts2.Core.Entities.Creatures; using MegaCrit.Sts2.Core.Entities.Players; using MegaCrit.Sts2.Core.Models; @@ -64,9 +65,17 @@ public override ExecuteResult Execute() target = CardPlayReplayPatch._currentCombatState?.GetCreature(TargetId); } - // Default to self when no target is specified. - if (target == null) - target = player.Creature; + if (target == null && !TargetId.HasValue) + { + target = potion.TargetType switch + { + TargetType.Self or TargetType.AnyPlayer => player.Creature, + TargetType.AnyEnemy => CardPlayReplayPatch._currentCombatState + ?.Enemies + ?.FirstOrDefault(enemy => enemy.IsAlive), + _ => null, + }; + } try { diff --git a/RunReplays/Patches/Replay/CardRewardReplayPatch.cs b/RunReplays/Patches/Replay/CardRewardReplayPatch.cs index e3c8760..99e0914 100644 --- a/RunReplays/Patches/Replay/CardRewardReplayPatch.cs +++ b/RunReplays/Patches/Replay/CardRewardReplayPatch.cs @@ -15,5 +15,6 @@ public static void Postfix(NCardRewardSelectionScreen __instance) return; ReplayState.CardRewardSelectionScreen = __instance; + ReplayDispatcher.DispatchNow(); } -} \ No newline at end of file +} diff --git a/RunReplays/ReplayDispatcher.cs b/RunReplays/ReplayDispatcher.cs index 15f5154..5e56657 100644 --- a/RunReplays/ReplayDispatcher.cs +++ b/RunReplays/ReplayDispatcher.cs @@ -1042,12 +1042,24 @@ private static void ExecuteNext() _dispatchInProgress = false; _lastDispatchedCmd = null; int gen = ++_dispatchGeneration; - NGame.Instance?.GetTree()?.CreateTimer(0.5f).Connect( - "timeout", Callable.From(() => + float delay = PostSuccessDelay(cmd); + if (delay <= 0f) + { + Callable.From(() => { if (_dispatchGeneration == gen) TryDispatch(); - })); + }).CallDeferred(); + } + else + { + NGame.Instance?.GetTree()?.CreateTimer(delay).Connect( + "timeout", Callable.From(() => + { + if (_dispatchGeneration == gen) + TryDispatch(); + })); + } return; } if (result.RetryDelayMs > 0) @@ -1070,6 +1082,13 @@ private static void ExecuteNext() PlayerActionBuffer.LogMigrationWarning($"[Dispatcher] Unrecognised command: {cmd}"); } + private static float PostSuccessDelay(ReplayCommand cmd) + { + return cmd is ClaimRewardCommand or TakeCardCommand or SkipRewardsCommand + ? 0.05f + : 0.5f; + } + private static DispatchSignalEmitter? _emitter; public static DispatchSignalEmitter? Emitter => _emitter;