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.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/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/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..578aaa3 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;
+
+ if (hand.Any(card => ReferenceEquals(card, resolved)))
+ 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;
+ }
+ }
+
+ 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/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/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..63163e3 100644
--- a/RunReplays/Commands/UsePotionCommand.cs
+++ b/RunReplays/Commands/UsePotionCommand.cs
@@ -1,4 +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;
@@ -40,8 +42,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();
@@ -64,13 +65,21 @@ 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
{
- 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..5c77ca8 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();
@@ -345,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;
@@ -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/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 1b82a2a..5e56657 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)
@@ -318,7 +336,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 }));
}
@@ -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));
@@ -456,7 +472,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));
@@ -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
@@ -1018,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)
@@ -1046,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;
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 50371be..05b7c08 100644
--- a/RunReplays/RunReplayMenu.cs
+++ b/RunReplays/RunReplayMenu.cs
@@ -28,6 +28,26 @@ 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);
+
+ 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,
@@ -46,6 +66,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 +84,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 +107,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 +118,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 +128,79 @@ 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);
+ }
+
+ 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()
+ .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 ───────────────────────────────────────────────────────
@@ -565,7 +672,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 +802,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);
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/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
}
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"