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); }