From 4e7d93a90f6bfb859591c8d8dfd7ba57267a1dcc Mon Sep 17 00:00:00 2001 From: Cee Date: Fri, 1 May 2026 20:19:00 -0700 Subject: [PATCH] Filter Unplayable cards (Ascenders Bane, Burn) out of policy choices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User noticed Ascenders Bane appearing in the played list. It's CardKeyword.Unplayable — should never be selectable. Policies were only filtering by energy cost, not the Unplayable keyword. Added Playable.IsPlayable / Playable.InHand helpers in IPlayPolicy.cs; all four policies now route through them. Smoke test verifies none of GreedyAttack / HighestDamage / Random / EpsilonGreedy ε=1 ever picks Ascenders Bane when it's the only attractive option. Note on the Hellraiser cascade ("12 cards in one turn") — that's correct behavior. With a Strike-tag-heavy deck, every drawn Strike auto-plays, including ones drawn by Pommel Strike's draw effect or Battle Trance+'s mass draw. The chain length is just deck composition. Co-Authored-By: Claude Opus 4.7 --- src/Policies/EpsilonGreedyPolicy.cs | 4 +--- src/Policies/GreedyAttackPolicy.cs | 3 +-- src/Policies/HighestDamagePolicy.cs | 5 +---- src/Policies/IPlayPolicy.cs | 21 ++++++++++++++++++ src/Policies/RandomPolicy.cs | 4 +--- src/SmokeTests.cs | 34 +++++++++++++++++++++++++++++ 6 files changed, 59 insertions(+), 12 deletions(-) diff --git a/src/Policies/EpsilonGreedyPolicy.cs b/src/Policies/EpsilonGreedyPolicy.cs index 9ced569..3746a9b 100644 --- a/src/Policies/EpsilonGreedyPolicy.cs +++ b/src/Policies/EpsilonGreedyPolicy.cs @@ -27,9 +27,7 @@ internal sealed class EpsilonGreedyPolicy : IPlayPolicy { if (rng.NextDouble() < _epsilon) { - var affordable = h.Player.PlayerCombatState!.Hand.Cards - .Where(c => c.EnergyCost.GetResolved() <= energyLeft) - .ToList(); + var affordable = Playable.InHand(h, energyLeft).ToList(); if (affordable.Count == 0) return null; return affordable[rng.Next(affordable.Count)]; } diff --git a/src/Policies/GreedyAttackPolicy.cs b/src/Policies/GreedyAttackPolicy.cs index 156b7b1..a637269 100644 --- a/src/Policies/GreedyAttackPolicy.cs +++ b/src/Policies/GreedyAttackPolicy.cs @@ -11,10 +11,9 @@ internal sealed class GreedyAttackPolicy : IPlayPolicy public CardModel? ChooseCard(Harness.CombatHarness h, int energyLeft, Random rng) { - foreach (var card in h.Player.PlayerCombatState!.Hand.Cards) + foreach (var card in Playable.InHand(h, energyLeft)) { if (card.Type != CardType.Attack) continue; - if (card.EnergyCost.GetResolved() > energyLeft) continue; return card; } return null; diff --git a/src/Policies/HighestDamagePolicy.cs b/src/Policies/HighestDamagePolicy.cs index 13cacc5..e7e1793 100644 --- a/src/Policies/HighestDamagePolicy.cs +++ b/src/Policies/HighestDamagePolicy.cs @@ -16,11 +16,8 @@ internal sealed class HighestDamagePolicy : IPlayPolicy public CardModel? ChooseCard(Harness.CombatHarness h, int energyLeft, Random rng) { - var affordable = h.Player.PlayerCombatState!.Hand.Cards - .Where(c => c.EnergyCost.GetResolved() <= energyLeft) - .ToList(); + var affordable = Playable.InHand(h, energyLeft).ToList(); if (affordable.Count == 0) return null; - return affordable.OrderByDescending(EstimatedDamage).First(); } diff --git a/src/Policies/IPlayPolicy.cs b/src/Policies/IPlayPolicy.cs index 3c37919..51d692f 100644 --- a/src/Policies/IPlayPolicy.cs +++ b/src/Policies/IPlayPolicy.cs @@ -1,4 +1,7 @@ using System; +using System.Collections.Generic; +using System.Linq; +using MegaCrit.Sts2.Core.Entities.Cards; using MegaCrit.Sts2.Core.Models; namespace StS2Sim; @@ -13,3 +16,21 @@ internal interface IPlayPolicy /// Pick a card to play, or null to end turn. CardModel? ChooseCard(Harness.CombatHarness h, int energyLeft, Random rng); } + +/// +/// Shared filtering logic so all policies treat "this card is playable right now" +/// identically. Excludes cards with the Unplayable keyword (Ascenders Bane, +/// Burn, etc.) and cards we can't afford. +/// +internal static class Playable +{ + public static bool IsPlayable(CardModel card, int energyLeft) + { + if (card.Keywords.Contains(CardKeyword.Unplayable)) return false; + if (card.EnergyCost.GetResolved() > energyLeft) return false; + return true; + } + + public static IEnumerable InHand(Harness.CombatHarness h, int energyLeft) + => h.Player.PlayerCombatState!.Hand.Cards.Where(c => IsPlayable(c, energyLeft)); +} diff --git a/src/Policies/RandomPolicy.cs b/src/Policies/RandomPolicy.cs index 1ac9591..27b2ccc 100644 --- a/src/Policies/RandomPolicy.cs +++ b/src/Policies/RandomPolicy.cs @@ -22,9 +22,7 @@ internal sealed class RandomPolicy : IPlayPolicy { if (_earlyEndProbability > 0 && rng.NextDouble() < _earlyEndProbability) return null; - var affordable = h.Player.PlayerCombatState!.Hand.Cards - .Where(c => c.EnergyCost.GetResolved() <= energyLeft) - .ToList(); + var affordable = Playable.InHand(h, energyLeft).ToList(); if (affordable.Count == 0) return null; return affordable[rng.Next(affordable.Count)]; } diff --git a/src/SmokeTests.cs b/src/SmokeTests.cs index 8614341..e92522a 100644 --- a/src/SmokeTests.cs +++ b/src/SmokeTests.cs @@ -40,6 +40,7 @@ internal static class SmokeTests Test_StrikePlus_Deals9, Test_Offering_GivesEnergyAndDraw, Test_Hellraiser_AutoplaysDrawnStrikes, + Test_AscendersBane_NotPlayedByPolicies, }; var results = new List(); @@ -125,6 +126,39 @@ internal static class SmokeTests return null; }); + private static Task Test_AscendersBane_NotPlayedByPolicies() + { + // Ascenders Bane has CardKeyword.Unplayable. Even if a policy considers + // every card in hand, none of the policies should ever choose it. + var h = Harness.BeginCombat(deckOverride: new List + { + typeof(AscendersBane), + typeof(StrikeIronclad), + }); + try + { + var pcs = h.Player.PlayerCombatState!; + Reflect.SetEnergy(pcs, 3); + foreach (var c in pcs.DrawPile.Cards.ToList()) { pcs.DrawPile.RemoveInternal(c); pcs.Hand.AddInternal(c); } + + var policies = new IPlayPolicy[] + { + new GreedyAttackPolicy(), + new HighestDamagePolicy(), + new RandomPolicy(), + new EpsilonGreedyPolicy(new HighestDamagePolicy(), 1.0), + }; + foreach (var p in policies) + { + var pick = p.ChooseCard(h, 3, new Random(0)); + if (pick is AscendersBane) + return Task.FromResult(new TestResult("Policies skip Unplayable cards", false, $"{p.Name} chose Ascenders Bane")); + } + return Task.FromResult(new TestResult("Policies skip Unplayable cards", true, null)); + } + finally { Harness.EndCombat(); } + } + private static async Task Test_Hellraiser_AutoplaysDrawnStrikes() { // After Hellraiser power is applied, drawing a Strike should auto-play it. -- knotmirror 0.0.1