Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
/.idea
RunReplays.sln.DotSettings.user
/RunReplays/.claude
/out
2 changes: 1 addition & 1 deletion RunReplays.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
38 changes: 35 additions & 3 deletions RunReplays/Commands/ClaimRewardCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
6 changes: 6 additions & 0 deletions RunReplays/Commands/EndTurnCommand.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using RunReplays.Patches.Replay;
using MegaCrit.Sts2.Core.Combat;
namespace RunReplays.Commands;

/// <summary>
Expand All @@ -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);
Expand Down
51 changes: 50 additions & 1 deletion RunReplays/Commands/PlayCardCommand.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -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))
Expand Down
6 changes: 6 additions & 0 deletions RunReplays/Commands/ReplayCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ public abstract class ReplayCommand
/// </summary>
public string? Comment { get; set; }

/// <summary>
/// Optional state snapshot suffix from minimal replay entries, delimited by
/// " || ". Commands can use this to recover recorder context.
/// </summary>
public string? StateSuffix { get; set; }

protected ReplayCommand(string rawText)
{
RawText = rawText;
Expand Down
151 changes: 149 additions & 2 deletions RunReplays/Commands/SelectGridCardCommand.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<CardModel>;
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<CardModel>();
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)
Expand All @@ -55,6 +82,126 @@ public override ExecuteResult Execute()
return ExecuteResult.Ok();
}

private static bool TryInferNegativeSelection(
IReadOnlyList<CardModel> cards,
out int inferredIndex)
{
inferredIndex = -1;

RunReplays.ReplayEngine.GetReplayContext(
out _,
out _,
out IReadOnlyList<ReplayCommand> 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<string>();

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<CardModel>? GetSelectableCards(NCardGridSelectionScreen screen)
{
if (CardGridScreenCapture.CardsField?.GetValue(screen) is IReadOnlyList<CardModel> cards
&& cards.Count > 0)
return cards;

var holderCards = new List<CardModel>();
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))
Expand Down
Loading