diff --git a/src/AutoCardSelector.cs b/src/AutoCardSelector.cs
new file mode 100644
index 0000000..6704590
--- /dev/null
+++ b/src/AutoCardSelector.cs
@@ -0,0 +1,31 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using MegaCrit.Sts2.Core.Entities.CardRewardAlternatives;
+using MegaCrit.Sts2.Core.Entities.Cards;
+using MegaCrit.Sts2.Core.Models;
+using MegaCrit.Sts2.Core.TestSupport;
+
+namespace StS2Sim;
+
+///
+/// Card-select prompts (Armaments "pick a card to upgrade", Havoc "pick a card to play",
+/// etc.) need a selector or they NRE. The game's normal selector talks to a UI;
+/// for headless sims we just auto-pick the first maxSelect options
+/// (or skip if minSelect == 0).
+///
+/// This is a "good enough" heuristic — it doesn't try to make optimal choices.
+/// For decks with select-heavy cards we could swap in a smarter strategy later.
+///
+internal sealed class AutoCardSelector : ICardSelector
+{
+ public Task> GetSelectedCards(IEnumerable options, int minSelect, int maxSelect)
+ {
+ var list = options.ToList();
+ var picked = list.Take(System.Math.Max(minSelect, System.Math.Min(maxSelect, list.Count)));
+ return Task.FromResult>(picked.ToList());
+ }
+
+ public CardModel? GetSelectedCardReward(IReadOnlyList options, IReadOnlyList alternatives)
+ => options.FirstOrDefault()?.Card;
+}
diff --git a/src/GodotShims.cs b/src/GodotShims.cs
index 436f86e..3b4fecd 100644
--- a/src/GodotShims.cs
+++ b/src/GodotShims.cs
@@ -46,6 +46,16 @@ internal static class GodotShims
type: typeof(Godot.Time),
methodName: "GetTicksMsec",
prefix: typeof(GodotShims).GetMethod(nameof(Time_GetTicksMsec_Prefix), BindingFlags.Static | BindingFlags.NonPublic)!);
+
+ // CardPileCmd.Shuffle calls Engine.GetMainLoop() for animation pacing
+ // (sleeps between adding cards back to draw pile). We replace the whole
+ // method with a synchronous shuffle that does the same logical work
+ // without ever touching SceneTree.
+ PatchPrefix(
+ harmony,
+ type: typeof(MegaCrit.Sts2.Core.Commands.CardPileCmd),
+ methodName: "Shuffle",
+ prefix: typeof(GodotShims).GetMethod(nameof(CardPileCmd_Shuffle_Prefix), BindingFlags.Static | BindingFlags.NonPublic)!);
}
private static void PatchPrefix(Harmony harmony, Type? type, string methodName, MethodInfo prefix)
@@ -84,6 +94,57 @@ internal static class GodotShims
return false;
}
+ // Replacement for CardPileCmd.Shuffle that skips the per-card animation wait
+ // (which calls Engine.GetMainLoop() and crashes outside Godot). Logic mirrors
+ // the original: pull discards, shuffle by player's RNG, re-add to draw pile.
+ private static bool CardPileCmd_Shuffle_Prefix(
+ MegaCrit.Sts2.Core.GameActions.Multiplayer.PlayerChoiceContext choiceContext,
+ MegaCrit.Sts2.Core.Entities.Players.Player player,
+ ref System.Threading.Tasks.Task __result)
+ {
+ __result = ShuffleSync(player);
+ return false;
+ }
+
+ private static System.Threading.Tasks.Task ShuffleSync(MegaCrit.Sts2.Core.Entities.Players.Player player)
+ {
+ var pcs = player.PlayerCombatState;
+ if (pcs == null) return System.Threading.Tasks.Task.CompletedTask;
+
+ var draw = pcs.DrawPile;
+ var discard = pcs.DiscardPile;
+ var combined = new System.Collections.Generic.List(discard.Cards);
+
+ var inDraw = new System.Collections.Generic.HashSet(draw.Cards);
+ foreach (var c in inDraw)
+ {
+ draw.RemoveInternal(c, silent: true);
+ combined.Add(c);
+ }
+
+ // Fisher-Yates with the player's shuffle RNG. Sufficient for our purposes —
+ // the game's StableShuffle has tie-breaking semantics for IComparable
+ // that CardModel doesn't satisfy, but card identity isn't tied here.
+ var rng = player.RunState.Rng.Shuffle;
+ for (int i = combined.Count - 1; i > 0; i--)
+ {
+ int j = rng.NextInt(0, i + 1);
+ (combined[i], combined[j]) = (combined[j], combined[i]);
+ }
+
+ // Re-add to draw pile (we drop the per-card wait because it's animation-only).
+ foreach (var c in combined)
+ {
+ if (inDraw.Contains(c)) draw.AddInternal(c, -1, silent: true);
+ else
+ {
+ discard.RemoveInternal(c, silent: true);
+ draw.AddInternal(c, -1, silent: true);
+ }
+ }
+ return System.Threading.Tasks.Task.CompletedTask;
+ }
+
// Localization isn't initialized in headless mode, so Creature.ToString() (which
// hits LocString.GetFormattedText()) throws. Patch it to return a safe identifier.
public static void ApplyLocalizationShim()
diff --git a/src/Harness.cs b/src/Harness.cs
index 84c530f..0ff0431 100644
--- a/src/Harness.cs
+++ b/src/Harness.cs
@@ -42,6 +42,10 @@ internal static class Harness
ModelDb.InitIds();
GodotShims.ApplyLocalizationShim();
+
+ // Cards like Armaments ("pick a card to upgrade") wait for a CardSelectCmd
+ // selector. Without one, they NRE. Register a global auto-picker.
+ MegaCrit.Sts2.Core.Commands.CardSelectCmd.UseSelector(new AutoCardSelector());
}
public sealed class CombatHarness
diff --git a/src/SimServer.cs b/src/SimServer.cs
index fbcb367..a6894b3 100644
--- a/src/SimServer.cs
+++ b/src/SimServer.cs
@@ -253,7 +253,8 @@ internal sealed class SimServer
}
catch (Exception ex)
{
- await BroadcastEvent(new { type = "error", message = ex.Message });
+ Console.Error.WriteLine("[SimServer] sim run threw:\n" + ex);
+ await BroadcastEvent(new { type = "error", message = ex.Message, stack = ex.ToString() });
}
}, ct);
}