From 65607cefd1d209cbaedd9d2f78233c1234110032 Mon Sep 17 00:00:00 2001 From: AAltriaa <15160049+aaltriaa@user.noreply.gitee.com> Date: Fri, 5 Jun 2026 23:33:25 +0800 Subject: [PATCH 1/4] fix(headless): ICardSelector.GetSelectedCardReward returns CardModel? in v0.103.3 STS2 v0.103.3 (build post-23372702) reverted the intermediate CardRewardSelection struct change and returns CardModel? directly. Null = skip, non-null = take that card. Match the decompiled ICardSelector signature. Co-Authored-By: Claude Opus 4.6 --- src/Sts2Headless/RunSimulator.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Sts2Headless/RunSimulator.cs b/src/Sts2Headless/RunSimulator.cs index 6b9de4a..2b47884 100644 --- a/src/Sts2Headless/RunSimulator.cs +++ b/src/Sts2Headless/RunSimulator.cs @@ -3367,15 +3367,15 @@ public void CancelPending() private ManualResetEventSlim? _rewardWait; private int _rewardChoice = -1; - // NOTE: STS2 build 23372702 changed ICardSelector.GetSelectedCardReward to return - // a CardRewardSelection struct { CardModel card; CardRewardAlternative alternative }. - // CardReward.OnSelect interprets: alternative != null → pick that alternative - // (Skip/Reroll); else card != null → take that card; both null → skip (no card kept). - public MegaCrit.Sts2.Core.TestSupport.CardRewardSelection GetSelectedCardReward( - IReadOnlyList options, + // NOTE: STS2 post-build 23372702 reverted ICardSelector.GetSelectedCardReward + // back to a `CardModel?` return type. Null means skip; non-null means take that card. + // (An earlier intermediate build used a CardRewardSelection struct { card, alternative } + // here, but it has since been removed upstream.) + public CardModel? GetSelectedCardReward( + IReadOnlyList options, IReadOnlyList alternatives) { - if (options.Count == 0) return default; // Skip + if (options.Count == 0) return null; // Skip // Store pending and block until main loop resolves PendingRewardCards = options.ToList(); @@ -3390,8 +3390,8 @@ public MegaCrit.Sts2.Core.TestSupport.CardRewardSelection GetSelectedCardReward( _rewardWait = null; if (choice >= 0 && choice < options.Count) - return new MegaCrit.Sts2.Core.TestSupport.CardRewardSelection { card = options[choice].Card }; - return default; // Skip (card=null, alternative=null) + return options[choice].Card; + return null; // Skip } public bool HasPendingReward => PendingRewardCards != null && _rewardWait != null; From ef8480719ca2dd06103dbf536afa51d2b03001ee Mon Sep 17 00:00:00 2001 From: AAltriaa <15160049+aaltriaa@user.noreply.gitee.com> Date: Fri, 5 Jun 2026 23:34:19 +0800 Subject: [PATCH 2/4] fix(headless): MerchantRoom.GetLocalInventory() -> Inventory property STS2 v0.103.3 moved MerchantRoom's inventory from a TestSupport extension method (GetLocalInventory) to an instance property (Inventory) of type MerchantInventory. The inventory content (CharacterCardEntries, ColorlessCardEntries, RelicEntries, PotionEntries, CardRemovalEntry) is unchanged; only the access path moved. Affects DoBuyCard, DoBuyRelic, DoBuyPotion, DoRemoveCard, and ShopState across 11 call sites in RunSimulator.cs. Co-Authored-By: Claude Opus 4.6 --- src/Sts2Headless/RunSimulator.cs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Sts2Headless/RunSimulator.cs b/src/Sts2Headless/RunSimulator.cs index 2b47884..60818f8 100644 --- a/src/Sts2Headless/RunSimulator.cs +++ b/src/Sts2Headless/RunSimulator.cs @@ -1276,8 +1276,8 @@ private bool IsPlayPhase() return Error("buy_card requires 'card_index'"); var idx = Convert.ToInt32(args["card_index"]); - var allEntries = merchantRoom.GetLocalInventory().CharacterCardEntries - .Concat(merchantRoom.GetLocalInventory().ColorlessCardEntries).ToList(); + var allEntries = merchantRoom.Inventory.CharacterCardEntries + .Concat(merchantRoom.Inventory.ColorlessCardEntries).ToList(); if (idx < 0 || idx >= allEntries.Count) return Error($"Invalid card index {idx}"); @@ -1287,7 +1287,7 @@ private bool IsPlayPhase() try { - entry.OnTryPurchaseWrapper(merchantRoom.GetLocalInventory()).GetAwaiter().GetResult(); + entry.OnTryPurchaseWrapper(merchantRoom.Inventory).GetAwaiter().GetResult(); _syncCtx.Pump(); Log($"Bought card: {entry.CreationResult?.Card?.GetType().Name ?? "?"} for {entry.Cost}g"); } @@ -1304,7 +1304,7 @@ private bool IsPlayPhase() return Error("buy_relic requires 'relic_index'"); var idx = Convert.ToInt32(args["relic_index"]); - var entries = merchantRoom.GetLocalInventory().RelicEntries; + var entries = merchantRoom.Inventory.RelicEntries; if (idx < 0 || idx >= entries.Count) return Error($"Invalid relic index {idx}"); var entry = entries[idx]; @@ -1317,7 +1317,7 @@ private bool IsPlayPhase() // Adroit, #80). Run the purchase on a background task and yield as soon as a // pending selection appears so the caller can resolve it; the background task // continues once the selector's TCS is fed by select_cards. - var inv = merchantRoom.GetLocalInventory(); + var inv = merchantRoom.Inventory; var task = Task.Run(() => entry.OnTryPurchaseWrapper(inv)); for (int i = 0; i < 100; i++) { @@ -1349,7 +1349,7 @@ private bool IsPlayPhase() return Error("buy_potion requires 'potion_index'"); var idx = Convert.ToInt32(args["potion_index"]); - var entries = merchantRoom.GetLocalInventory().PotionEntries; + var entries = merchantRoom.Inventory.PotionEntries; if (idx < 0 || idx >= entries.Count) return Error($"Invalid potion index {idx}"); var entry = entries[idx]; @@ -1358,7 +1358,7 @@ private bool IsPlayPhase() try { - entry.OnTryPurchaseWrapper(merchantRoom.GetLocalInventory()).GetAwaiter().GetResult(); + entry.OnTryPurchaseWrapper(merchantRoom.Inventory).GetAwaiter().GetResult(); _syncCtx.Pump(); Log($"Bought potion: {entry.Model.GetType().Name} for {entry.Cost}g"); } @@ -1376,14 +1376,14 @@ private bool IsPlayPhase() if (_runState?.CurrentRoom is not MerchantRoom merchantRoom) return Error("Not in a shop"); - var removal = merchantRoom.GetLocalInventory().CardRemovalEntry; + var removal = merchantRoom.Inventory.CardRemovalEntry; if (removal == null) return Error("No card removal available"); if (player.Gold < removal.Cost) return Error("Not enough gold"); try { // Run on background thread so card selection can pause (same pattern as event options) - var task = Task.Run(() => removal.OnTryPurchaseWrapper(merchantRoom.GetLocalInventory())); + var task = Task.Run(() => removal.OnTryPurchaseWrapper(merchantRoom.Inventory)); for (int i = 0; i < 100; i++) { _syncCtx.Pump(); @@ -2690,7 +2690,7 @@ private void ForceToMap() private Dictionary ShopState(MerchantRoom merchantRoom, Player player) { - var inv = merchantRoom.GetLocalInventory(); + var inv = merchantRoom.Inventory; if (inv == null) { ForceToMap(); return MapSelectState(); } var cards = inv.CharacterCardEntries.Concat(inv.ColorlessCardEntries) @@ -2755,7 +2755,7 @@ private void ForceToMap() ["is_stocked"] = e.IsStocked, }).ToList(); - var removal = merchantRoom.GetLocalInventory().CardRemovalEntry; + var removal = merchantRoom.Inventory.CardRemovalEntry; return new Dictionary { From efb03ba88f48e985004c4d186096c1a046328ab2 Mon Sep 17 00:00:00 2001 From: AAltriaa <15160049+aaltriaa@user.noreply.gitee.com> Date: Sat, 6 Jun 2026 00:07:24 +0800 Subject: [PATCH 3/4] fix: migrate to sts2.dll v0.103.3 API changes - IsPlayPhase: use CombatManager.Instance.IsPlayPhase property - Reward auto-collect: SelectUnsynchronized() -> OnSelectWrapper() - PowerCmd.Apply: remove PlayerChoiceContext param (6->5 args) --- src/Sts2Headless/RunSimulator.cs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/Sts2Headless/RunSimulator.cs b/src/Sts2Headless/RunSimulator.cs index 60818f8..8c2a22c 100644 --- a/src/Sts2Headless/RunSimulator.cs +++ b/src/Sts2Headless/RunSimulator.cs @@ -1022,13 +1022,11 @@ private static bool TryRollbackSerializedSaveToPreRoom(SerializableRun serializa return DetectDecisionPoint(); } - // STS2 build 23372702 removed CombatManager.IsPlayPhase (global) in favor of a - // per-player PlayerCombatState.Phase. Headless is single-player, so the local - // player (Players[0]) being in the Play phase is the equivalent signal. + // Newer STS2 builds removed PlayerCombatState.Phase / PlayerTurnPhase in favor of a + // simple CombatManager.Instance.IsPlayPhase property. private bool IsPlayPhase() { - var p = (_runState != null && _runState.Players.Count > 0) ? _runState.Players[0] : null; - return p?.PlayerCombatState?.Phase == MegaCrit.Sts2.Core.Combat.PlayerTurnPhase.Play; + return CombatManager.Instance.IsPlayPhase; } private Dictionary DoEndTurn(Player player) @@ -2374,7 +2372,7 @@ private void HealBetweenActs() if (reward is GoldReward || reward is MegaCrit.Sts2.Core.Rewards.RelicReward || reward is MegaCrit.Sts2.Core.Rewards.PotionReward) { - try { reward.SelectUnsynchronized().GetAwaiter().GetResult(); _syncCtx.Pump(); } + try { reward.OnSelectWrapper().GetAwaiter().GetResult(); _syncCtx.Pump(); } catch (Exception ex) { Log($"Auto-collect reward: {ex.Message}"); } } else if (reward is CardReward cr) @@ -3735,7 +3733,7 @@ private static async Task NeutralizeSafe(CardModel card, PlayerChoiceContext ctx { await CreatureCmd.Damage(ctx, play.Target!, card.DynamicVars.Damage.BaseValue, MegaCrit.Sts2.Core.ValueProps.ValueProp.Move, card); - await PowerCmd.Apply(ctx, play.Target!, card.DynamicVars["WeakPower"].BaseValue, + await PowerCmd.Apply(play.Target!, card.DynamicVars["WeakPower"].BaseValue, card.Owner.Creature, card, false); } catch (Exception ex) { Console.Error.WriteLine($"[WARN] Neutralize safe: {ex.Message}"); } From 8d0a4543e4000ba735e80153f83ffc2b1e51007e Mon Sep 17 00:00:00 2001 From: AAltriaa <15160049+aaltriaa@user.noreply.gitee.com> Date: Sat, 6 Jun 2026 10:34:06 +0800 Subject: [PATCH 4/4] chore: ignore test_scripts/ --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2b66749..940fd85 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ obj/ .obsidian/ logs/ saves/* +test_scripts/