From 7f696af7ddff2c32383a4980dbffbaaff7bc99b9 Mon Sep 17 00:00:00 2001 From: Cee Date: Sun, 3 May 2026 20:25:43 -0700 Subject: [PATCH] Add Silent card test battery (174 tests, 95% pass) Per-card smoke tests for all 87 Silent cards (base + upgraded), split into 6 thematic buckets so parallel agents could work on them without stepping on each other: Bucket 1 - Basic Attacks (15 cards) Bucket 2 - Multi-hit & Shiv Attacks (15 cards) Bucket 3 - Conditional/Burst Attacks (12 cards) Bucket 4 - Defensive & Draw Skills (15 cards) Bucket 5 - Utility & Card Manipulation (16 cards) Bucket 6 - Powers & Poison Suite (14 cards) Each test runs an isolated combat against BigDummy, plays the card, and asserts on HP/block/energy/power deltas. Four reusable patterns in TestHelpers (SingleCardTest, PowerThenPlayTest, SequenceTest, PreloadHandTest) plus a Pass/Fail/Crash/Skipped result classification so harness bugs surface as Crash separately from "test got the wrong number". Run via: dotnet run -c Release -- silent-tests Current state: 166 pass, 0 fail, 0 crash, 8 skip. Skips are all genuine harness limitations (RandomEnemy targeting needs multi-enemy harness; MultiplayerOnly cards like Flanking need a RunManager shim; CombatRoom-gated cards like TheHunt need a real CombatRoom on the run state; CardSelectCmd discard needs hand fillers). Documented per skip. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/SilentTests/AGENT_BRIEF.md | 136 +++++++ src/SilentTests/Bucket1_BasicAttacks.cs | 271 +++++++++++++ src/SilentTests/Bucket2_MultiHitShiv.cs | 303 ++++++++++++++ src/SilentTests/Bucket3_ConditionalBurst.cs | 314 +++++++++++++++ src/SilentTests/Bucket4_DefensiveDraw.cs | 385 ++++++++++++++++++ src/SilentTests/Bucket5_UtilityCardManip.cs | 416 ++++++++++++++++++++ src/SilentTests/Bucket6_PowersPoison.cs | 343 ++++++++++++++++ src/SilentTests/SilentTestsRunner.cs | 113 ++++++ src/SilentTests/TestHelpers.cs | 315 +++++++++++++++ 9 files changed, 2596 insertions(+) create mode 100644 src/SilentTests/AGENT_BRIEF.md create mode 100644 src/SilentTests/Bucket1_BasicAttacks.cs create mode 100644 src/SilentTests/Bucket2_MultiHitShiv.cs create mode 100644 src/SilentTests/Bucket3_ConditionalBurst.cs create mode 100644 src/SilentTests/Bucket4_DefensiveDraw.cs create mode 100644 src/SilentTests/Bucket5_UtilityCardManip.cs create mode 100644 src/SilentTests/Bucket6_PowersPoison.cs create mode 100644 src/SilentTests/SilentTestsRunner.cs create mode 100644 src/SilentTests/TestHelpers.cs diff --git a/src/SilentTests/AGENT_BRIEF.md b/src/SilentTests/AGENT_BRIEF.md new file mode 100644 index 0000000..6766144 --- /dev/null +++ b/src/SilentTests/AGENT_BRIEF.md @@ -0,0 +1,136 @@ +# Silent card test battery — agent brief + +You are one of six parallel agents writing tests for Silent's 88 cards in the StS2 headless sim. Your bucket is a slice of those cards; the other agents handle the rest. **Stay in your file** — don't touch other bucket files or shared infrastructure. + +## Your job + +For every card in your assigned bucket (listed in your file's class doc-comment), write a test that: + +1. **Reads the card's actual implementation** at `C:\Users\jhol\slaythespiredata\sts2-decompiled\MegaCrit\sts2\Core\Models\Cards\.cs` to derive the expected behavior. **Do not guess damage/block numbers from STS1 memory** — STS2 numbers differ. +2. **Asserts a specific delta** (HP, block, energy, hand size, presence of a power). +3. **Tests both the base card and the upgraded variant** (`+`). Two test methods per card. +4. **Returns a `TestHelpers.TestResult`** so the runner can categorize PASS / FAIL / CRASH / SKIPPED. + +## Test patterns (use these — don't reinvent) + +All four live in `TestHelpers.cs`. Match the pattern to the card: + +| Pattern | When to use | Example | +|---|---|---| +| `SingleCardTest` | Card does its thing in isolation: damage, block, applies a power | `Strike deals 6` | +| `PowerThenPlayTest` | Card's value depends on a power being active | `Strike into Vulnerable does 9` | +| `SequenceTest` | Need to play multiple cards and check the final state | `Inflame then Strike does 8` | +| `PreloadHandTest` | Effect depends on what's in hand (HandTrick, Reflex, CalculatedGamble) | `HandTrick scales with cards in hand` | + +**If a card needs something none of these patterns supports** (random target, monster intent, shuffle a specific seed, choose-card-from-discard) — call `TestHelpers.Skip(name, reason)` with a clear reason. Don't write an unreliable test. Skips are first-class results. + +## File structure + +Your file already exists with the class scaffolding. Replace the `RunAll()` body with something like: + +```csharp +public static async Task> RunAll() +{ + var results = new List(); + results.Add(await Test_StrikeSilent()); + results.Add(await Test_StrikeSilentPlus()); + results.Add(await Test_Sneaky()); + results.Add(await Test_SneakyPlus()); + // ... one Add per test + return results; +} + +private static Task Test_StrikeSilent() + => TestHelpers.SingleCardTest( + name: "StrikeSilent deals 6", + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 6, "damage")); + +private static Task Test_StrikeSilentPlus() + => TestHelpers.SingleCardTest( + name: "StrikeSilent+ deals 9", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 9, "damage")); +``` + +Methods can be sync (`Task.FromResult` via the helper) or `async` — both work because helpers return `Task`. + +## Reading card source + +Each `*.cs` card file has an `OnPlay` method that is the source of truth. Look for: +- `DamageCmd.Attack()` — base damage. The value is from `DynamicVars.Damage.BaseValue`. +- `BlockCmd.Add()` — block. +- `PowerCmd.Apply(target, amount, ...)` — power application. +- `OnUpgrade()` — what changes when upgraded. Often `BaseValue.UpgradeValueBy(N)` or `EnergyCost.UpgradeBy(-1)`. + +Example — `Neutralize.cs`: +```csharp +new DamageVar(3m, ValueProp.Move), // base damage 3 +new PowerVar(1m) // applies 1 Weak +// ... +protected override void OnUpgrade() +{ + base.DynamicVars.Damage.UpgradeValueBy(1m); // +1 dmg → 4 + base.DynamicVars.Weak.UpgradeValueBy(1m); // +1 Weak → 2 +} +``` + +So `Test_Neutralize` expects 3 damage + 1 Weak; `Test_NeutralizePlus` expects 4 damage + 2 Weak. + +## Power IDs (for `ExpectPower`) + +Use a substring match because the game's `Id.Entry` strings are ALL_CAPS_UNDERSCORED: + +- `"VULNERABLE"`, `"WEAK"`, `"FRAIL"` +- `"STRENGTH"`, `"DEXTERITY"` +- `"POISON"` +- `"ARTIFACT"`, `"INTANGIBLE"` + +Example: +```csharp +assert: (h, before) => +{ + if (before.dummyHp - h.Dummy.CurrentHp != 3) return "wrong damage"; + return TestHelpers.ExpectPower(h.Dummy, "WEAK", expectedAmount: 1); +} +``` + +## Naming convention + +- Method name: `Test_` and `Test_Plus` +- Display name (the `name:` arg): `" "` — keep it short, this is what shows in the test runner output. + +## What counts as a PASS, FAIL, CRASH, or SKIPPED + +- **PASS** = the assertion returned `null`. Damage/block/power matched expected. +- **FAIL** = your assertion returned a string. Don't guess; the runner shows your string verbatim. +- **CRASH** = the harness threw an exception running the card. **Do not try to fix this.** Just let it crash — the runner classifies it. The test author for the harness will triage. +- **SKIPPED** = you decided the card uses a mechanic the harness can't handle. Use `TestHelpers.Skip(name, reason)` and write a one-sentence reason. + +## Don't do + +- Don't add new helpers to `TestHelpers.cs` — that's shared infrastructure other agents are using simultaneously. +- Don't touch other bucket files. +- Don't try to fix harness crashes — log them, move on. The whole point is to find them. +- Don't fabricate damage numbers. If you can't tell what the card does from its source, mark it `Skip`. +- Don't add tests for cards that aren't in your bucket. +- Don't use Python scripts with emoji output. + +## Verifying your work + +When you're done, build: +``` +cd C:\Users\jhol\slaythespiredata\StS2Sim +dotnet build -c Release -p:STS2GameDir="C:\Program Files (x86)\Steam\steamapps\common\Slay the Spire 2" +``` +Must end with `0 Error(s)`. Server may be running on port 52324 — if the build fails because `StS2Sim.exe` is locked, run `Stop-Process -Name "StS2Sim" -Force -ErrorAction SilentlyContinue` first. + +You don't need to run the full battery yourself — the orchestrator will do that across all six buckets at the end. Just make sure your file compiles. + +## Reporting back + +When done, return a brief summary: +- Total tests written (target: 2× the number of cards in your bucket) +- Anything you `Skip`ped and why +- Anything that surprised you about the card mechanics (might be a bug in our harness) + +That's it. Get going. diff --git a/src/SilentTests/Bucket1_BasicAttacks.cs b/src/SilentTests/Bucket1_BasicAttacks.cs new file mode 100644 index 0000000..ab25e15 --- /dev/null +++ b/src/SilentTests/Bucket1_BasicAttacks.cs @@ -0,0 +1,271 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MegaCrit.Sts2.Core.Models.Cards; + +namespace StS2Sim.SilentTests; + +/// +/// Bucket 1 — Basic Attacks. Pure damage with at most one simple side effect +/// (Weak/Vulnerable applied, draw 1, etc.). All cards in this bucket should +/// fit the SingleCardTest<T> pattern. +/// +/// Scope (15 cards × 2 upgrade levels = 30 tests): +/// StrikeSilent, Sneaky, Slice, Pounce, FlickFlack, LeadingStrike, +/// EchoingSlash, PreciseCut, Murder, Flanking, FollowThrough, Backstab, +/// MementoMori, Neutralize, DaggerThrow +/// +internal static class Bucket1_BasicAttacks +{ + public static async Task> RunAll() + { + var results = new List + { + await Test_StrikeSilent(), + await Test_StrikeSilentPlus(), + await Test_Sneaky(), + await Test_SneakyPlus(), + await Test_Slice(), + await Test_SlicePlus(), + await Test_Pounce(), + await Test_PouncePlus(), + await Test_FlickFlack(), + await Test_FlickFlackPlus(), + await Test_LeadingStrike(), + await Test_LeadingStrikePlus(), + await Test_EchoingSlash(), + await Test_EchoingSlashPlus(), + await Test_PreciseCut(), + await Test_PreciseCutPlus(), + await Test_Murder(), + await Test_MurderPlus(), + await Test_Flanking(), + await Test_FlankingPlus(), + await Test_FollowThrough(), + await Test_FollowThroughPlus(), + await Test_Backstab(), + await Test_BackstabPlus(), + await Test_MementoMori(), + await Test_MementoMoriPlus(), + await Test_Neutralize(), + await Test_NeutralizePlus(), + await Test_DaggerThrow(), + await Test_DaggerThrowPlus(), + }; + return results; + } + + // ─── StrikeSilent: 6 dmg / 9 dmg ─────────────────────────────────────── + private static Task Test_StrikeSilent() + => TestHelpers.SingleCardTest( + name: "StrikeSilent deals 6", + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 6, "damage")); + + private static Task Test_StrikeSilentPlus() + => TestHelpers.SingleCardTest( + name: "StrikeSilent+ deals 9", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 9, "damage")); + + // ─── Sneaky: applies SneakyPower 1 / 2 (multiplayer-only Power) ─────── + private static Task Test_Sneaky() + => TestHelpers.SingleCardTest( + name: "Sneaky applies SneakyPower 1", + assert: (h, before) => TestHelpers.ExpectPower(h.Player.Creature, "SNEAKY", expectedAmount: 1)); + + private static Task Test_SneakyPlus() + => TestHelpers.SingleCardTest( + name: "Sneaky+ applies SneakyPower 2", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.ExpectPower(h.Player.Creature, "SNEAKY", expectedAmount: 2)); + + // ─── Slice: 6 dmg / 9 dmg (0-cost) ──────────────────────────────────── + private static Task Test_Slice() + => TestHelpers.SingleCardTest( + name: "Slice deals 6", + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 6, "damage")); + + private static Task Test_SlicePlus() + => TestHelpers.SingleCardTest( + name: "Slice+ deals 9", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 9, "damage")); + + // ─── Pounce: 12 dmg + FreeSkill 1 / 18 dmg + FreeSkill 1 ────────────── + private static Task Test_Pounce() + => TestHelpers.SingleCardTest( + name: "Pounce deals 12 + FreeSkill 1", + assert: (h, before) => + { + if (before.dummyHp - h.Dummy.CurrentHp != 12) return $"expected damage=12, got {before.dummyHp - h.Dummy.CurrentHp}"; + return TestHelpers.ExpectPower(h.Player.Creature, "FREE_SKILL", expectedAmount: 1); + }); + + private static Task Test_PouncePlus() + => TestHelpers.SingleCardTest( + name: "Pounce+ deals 18 + FreeSkill 1", + upgradeLevel: 1, + assert: (h, before) => + { + if (before.dummyHp - h.Dummy.CurrentHp != 18) return $"expected damage=18, got {before.dummyHp - h.Dummy.CurrentHp}"; + return TestHelpers.ExpectPower(h.Player.Creature, "FREE_SKILL", expectedAmount: 1); + }); + + // ─── FlickFlack: 6 / 8 (AOE; single dummy still takes full hit) ─────── + private static Task Test_FlickFlack() + => TestHelpers.SingleCardTest( + name: "FlickFlack deals 6 (AOE)", + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 6, "damage")); + + private static Task Test_FlickFlackPlus() + => TestHelpers.SingleCardTest( + name: "FlickFlack+ deals 8 (AOE)", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 8, "damage")); + + // ─── LeadingStrike: 3 dmg + 2 Shivs / 6 dmg + 2 Shivs ───────────────── + private static Task Test_LeadingStrike() + => TestHelpers.SingleCardTest( + name: "LeadingStrike deals 3 + 2 Shivs", + assert: (h, before) => + { + var dmg = before.dummyHp - h.Dummy.CurrentHp; + if (dmg != 3) return $"expected damage=3, got {dmg}"; + // Two Shivs added to hand. Hand started with 0 (we removed LeadingStrike). + // After play, hand should contain 2 Shivs. + var shivCount = h.Player.PlayerCombatState!.Hand.Cards.Count(c => c.GetType().Name == "Shiv"); + if (shivCount != 2) return $"expected 2 Shivs in hand, got {shivCount}"; + return null; + }); + + private static Task Test_LeadingStrikePlus() + => TestHelpers.SingleCardTest( + name: "LeadingStrike+ deals 6 + 2 Shivs", + upgradeLevel: 1, + assert: (h, before) => + { + var dmg = before.dummyHp - h.Dummy.CurrentHp; + if (dmg != 6) return $"expected damage=6, got {dmg}"; + var shivCount = h.Player.PlayerCombatState!.Hand.Cards.Count(c => c.GetType().Name == "Shiv"); + if (shivCount != 2) return $"expected 2 Shivs in hand, got {shivCount}"; + return null; + }); + + // ─── EchoingSlash: 10 / 13 (one hit; AOE but dummy survives) ────────── + private static Task Test_EchoingSlash() + => TestHelpers.SingleCardTest( + name: "EchoingSlash deals 10", + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 10, "damage")); + + private static Task Test_EchoingSlashPlus() + => TestHelpers.SingleCardTest( + name: "EchoingSlash+ deals 13", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 13, "damage")); + + // ─── PreciseCut: 13 - 2*(other cards in hand). Empty hand → 13 / 16 ── + private static Task Test_PreciseCut() + => TestHelpers.SingleCardTest( + name: "PreciseCut deals 13 (empty hand)", + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 13, "damage")); + + private static Task Test_PreciseCutPlus() + => TestHelpers.SingleCardTest( + name: "PreciseCut+ deals 16 (empty hand)", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 16, "damage")); + + // ─── Murder: 1 + 1*(cards drawn this combat). Harness draws 0 → 1 / 1 ─ + // Upgrade reduces cost from 3 to 2 — damage is unchanged. + private static Task Test_Murder() + => TestHelpers.SingleCardTest( + name: "Murder deals 1 (no draws)", + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 1, "damage")); + + private static Task Test_MurderPlus() + => TestHelpers.SingleCardTest( + name: "Murder+ deals 1 (no draws)", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 1, "damage")); + + // ─── Flanking ─── MultiplayerOnly. FlankingPower.AfterApplied reads + // RunManager.Instance.NetService.Platform → NRE in headless. + // Skip until we shim RunManager or guard MP-only paths. + private static Task Test_Flanking() + => TestHelpers.Skip("Flanking applies FlankingPower 2", "MultiplayerOnly: FlankingPower.AfterApplied reads RunManager.Instance.NetService.Platform which is null in headless. Needs RunManager shim."); + + private static Task Test_FlankingPlus() + => TestHelpers.Skip("Flanking+ applies FlankingPower 2 (cost reduced)", "MultiplayerOnly — same NRE as base Flanking."); + + // ─── FollowThrough: 7 / 9 (one hit when hand has <5 other cards) ────── + private static Task Test_FollowThrough() + => TestHelpers.SingleCardTest( + name: "FollowThrough deals 7 (no extra hand)", + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 7, "damage")); + + private static Task Test_FollowThroughPlus() + => TestHelpers.SingleCardTest( + name: "FollowThrough+ deals 9 (no extra hand)", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 9, "damage")); + + // ─── Backstab: 11 / 15 ──────────────────────────────────────────────── + private static Task Test_Backstab() + => TestHelpers.SingleCardTest( + name: "Backstab deals 11", + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 11, "damage")); + + private static Task Test_BackstabPlus() + => TestHelpers.SingleCardTest( + name: "Backstab+ deals 15", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 15, "damage")); + + // ─── MementoMori: 9 + 4*discardsThisTurn / 11 + 5*discardsThisTurn ─── + // Empty turn history → 9 / 11. + private static Task Test_MementoMori() + => TestHelpers.SingleCardTest( + name: "MementoMori deals 9 (no discards)", + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 9, "damage")); + + private static Task Test_MementoMoriPlus() + => TestHelpers.SingleCardTest( + name: "MementoMori+ deals 11 (no discards)", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 11, "damage")); + + // ─── Neutralize: 3 dmg + Weak 1 / 4 dmg + Weak 2 ───────────────────── + private static Task Test_Neutralize() + => TestHelpers.SingleCardTest( + name: "Neutralize deals 3 + Weak 1", + assert: (h, before) => + { + var dmg = before.dummyHp - h.Dummy.CurrentHp; + if (dmg != 3) return $"expected damage=3, got {dmg}"; + return TestHelpers.ExpectPower(h.Dummy, "WEAK", expectedAmount: 1); + }); + + private static Task Test_NeutralizePlus() + => TestHelpers.SingleCardTest( + name: "Neutralize+ deals 4 + Weak 2", + upgradeLevel: 1, + assert: (h, before) => + { + var dmg = before.dummyHp - h.Dummy.CurrentHp; + if (dmg != 4) return $"expected damage=4, got {dmg}"; + return TestHelpers.ExpectPower(h.Dummy, "WEAK", expectedAmount: 2); + }); + + // ─── DaggerThrow: 9 / 12 dmg, then draw 1 + force discard 1 ────────── + // CardSelectCmd from empty hand should no-op (cardModel == null branch). + private static Task Test_DaggerThrow() + => TestHelpers.SingleCardTest( + name: "DaggerThrow deals 9", + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 9, "damage")); + + private static Task Test_DaggerThrowPlus() + => TestHelpers.SingleCardTest( + name: "DaggerThrow+ deals 12", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 12, "damage")); +} diff --git a/src/SilentTests/Bucket2_MultiHitShiv.cs b/src/SilentTests/Bucket2_MultiHitShiv.cs new file mode 100644 index 0000000..0471d04 --- /dev/null +++ b/src/SilentTests/Bucket2_MultiHitShiv.cs @@ -0,0 +1,303 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MegaCrit.Sts2.Core.Models.Cards; + +namespace StS2Sim.SilentTests; + +/// +/// Bucket 2 — Multi-hit & Shiv Attacks. Cards that hit multiple times, +/// generate Shivs, or scale damage based on shivs/cards in hand. +/// +/// Scope (15 cards × 2 upgrade levels = 30 tests): +/// DaggerSpray, FanOfKnives, BladeDance, CloakAndDagger, Accuracy, +/// BladeOfInk, PhantomBlades, Predator, Skewer, Snakebite, Ricochet, +/// StormOfSteel, Outbreak, HiddenDaggers, HandTrick +/// +internal static class Bucket2_MultiHitShiv +{ + public static async Task> RunAll() + { + var results = new List + { + await Test_DaggerSpray(), + await Test_DaggerSprayPlus(), + await Test_FanOfKnives(), + await Test_FanOfKnivesPlus(), + await Test_BladeDance(), + await Test_BladeDancePlus(), + await Test_CloakAndDagger(), + await Test_CloakAndDaggerPlus(), + await Test_Accuracy(), + await Test_AccuracyPlus(), + await Test_BladeOfInk(), + await Test_BladeOfInkPlus(), + await Test_PhantomBlades(), + await Test_PhantomBladesPlus(), + await Test_Predator(), + await Test_PredatorPlus(), + await Test_Skewer(), + await Test_SkewerPlus(), + await Test_Snakebite(), + await Test_SnakebitePlus(), + await Test_Ricochet(), + await Test_RicochetPlus(), + await Test_StormOfSteel(), + await Test_StormOfSteelPlus(), + await Test_Outbreak(), + await Test_OutbreakPlus(), + await Test_HiddenDaggers(), + await Test_HiddenDaggersPlus(), + await Test_HandTrick(), + await Test_HandTrickPlus(), + }; + return results; + } + + // ── DaggerSpray: 4 dmg × 2 hits to all enemies. Single dummy → 8 total. + // Upgrade +2 dmg → 6 × 2 = 12. + private static Task Test_DaggerSpray() + => TestHelpers.SingleCardTest( + name: "DaggerSpray deals 4×2=8 to single dummy", + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 8, "damage")); + + private static Task Test_DaggerSprayPlus() + => TestHelpers.SingleCardTest( + name: "DaggerSpray+ deals 6×2=12 to single dummy", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 12, "damage")); + + // ── FanOfKnives: power, applies FanOfKnivesPower(1) and creates 4 shivs (5 upgraded). + private static Task Test_FanOfKnives() + => TestHelpers.SingleCardTest( + name: "FanOfKnives creates 4 shivs + applies power", + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + int shivs = pcs.Hand.Cards.Count(c => c.GetType() == typeof(Shiv)); + if (shivs != 4) return $"expected 4 shivs in hand, got {shivs}"; + return TestHelpers.ExpectPower(h.Player.Creature, "FAN_OF_KNIVES", expectedAmount: 1); + }); + + private static Task Test_FanOfKnivesPlus() + => TestHelpers.SingleCardTest( + name: "FanOfKnives+ creates 5 shivs + applies power", + upgradeLevel: 1, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + int shivs = pcs.Hand.Cards.Count(c => c.GetType() == typeof(Shiv)); + if (shivs != 5) return $"expected 5 shivs in hand, got {shivs}"; + return TestHelpers.ExpectPower(h.Player.Creature, "FAN_OF_KNIVES", expectedAmount: 1); + }); + + // ── BladeDance: 3 shivs (4 upgraded), exhausts. + private static Task Test_BladeDance() + => TestHelpers.SingleCardTest( + name: "BladeDance creates 3 shivs", + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + int shivs = pcs.Hand.Cards.Count(c => c.GetType() == typeof(Shiv)); + return TestHelpers.Expect(shivs, 3, "shivs in hand"); + }); + + private static Task Test_BladeDancePlus() + => TestHelpers.SingleCardTest( + name: "BladeDance+ creates 4 shivs", + upgradeLevel: 1, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + int shivs = pcs.Hand.Cards.Count(c => c.GetType() == typeof(Shiv)); + return TestHelpers.Expect(shivs, 4, "shivs in hand"); + }); + + // ── CloakAndDagger: 6 block + 1 shiv (2 shivs upgraded). Block stays at 6. + private static Task Test_CloakAndDagger() + => TestHelpers.SingleCardTest( + name: "CloakAndDagger gives 6 block + 1 shiv", + assert: (h, before) => + { + if (h.Player.Creature.Block - before.playerBlock != 6) return $"expected block=6, got {h.Player.Creature.Block - before.playerBlock}"; + var pcs = h.Player.PlayerCombatState!; + int shivs = pcs.Hand.Cards.Count(c => c.GetType() == typeof(Shiv)); + return TestHelpers.Expect(shivs, 1, "shivs in hand"); + }); + + private static Task Test_CloakAndDaggerPlus() + => TestHelpers.SingleCardTest( + name: "CloakAndDagger+ gives 6 block + 2 shivs", + upgradeLevel: 1, + assert: (h, before) => + { + if (h.Player.Creature.Block - before.playerBlock != 6) return $"expected block=6, got {h.Player.Creature.Block - before.playerBlock}"; + var pcs = h.Player.PlayerCombatState!; + int shivs = pcs.Hand.Cards.Count(c => c.GetType() == typeof(Shiv)); + return TestHelpers.Expect(shivs, 2, "shivs in hand"); + }); + + // ── Accuracy: applies AccuracyPower 4 (6 upgraded). Power buffs shivs by +N damage. + private static Task Test_Accuracy() + => TestHelpers.SingleCardTest( + name: "Accuracy applies AccuracyPower 4", + assert: (h, before) => TestHelpers.ExpectPower(h.Player.Creature, "ACCURACY", expectedAmount: 4)); + + private static Task Test_AccuracyPlus() + => TestHelpers.SingleCardTest( + name: "Accuracy+ applies AccuracyPower 6", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.ExpectPower(h.Player.Creature, "ACCURACY", expectedAmount: 6)); + + // ── BladeOfInk: 2 shivs, all enchanted with Inky (3 upgraded). + private static Task Test_BladeOfInk() + => TestHelpers.SingleCardTest( + name: "BladeOfInk creates 2 shivs", + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + int shivs = pcs.Hand.Cards.Count(c => c.GetType() == typeof(Shiv)); + return TestHelpers.Expect(shivs, 2, "shivs in hand"); + }); + + private static Task Test_BladeOfInkPlus() + => TestHelpers.SingleCardTest( + name: "BladeOfInk+ creates 3 shivs", + upgradeLevel: 1, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + int shivs = pcs.Hand.Cards.Count(c => c.GetType() == typeof(Shiv)); + return TestHelpers.Expect(shivs, 3, "shivs in hand"); + }); + + // ── PhantomBlades: applies PhantomBladesPower 9 (12 upgraded). + private static Task Test_PhantomBlades() + => TestHelpers.SingleCardTest( + name: "PhantomBlades applies PhantomBladesPower 9", + assert: (h, before) => TestHelpers.ExpectPower(h.Player.Creature, "PHANTOM_BLADES", expectedAmount: 9)); + + private static Task Test_PhantomBladesPlus() + => TestHelpers.SingleCardTest( + name: "PhantomBlades+ applies PhantomBladesPower 12", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.ExpectPower(h.Player.Creature, "PHANTOM_BLADES", expectedAmount: 12)); + + // ── Predator: 15 dmg, +2 next-turn cards (20 dmg upgraded). + private static Task Test_Predator() + => TestHelpers.SingleCardTest( + name: "Predator deals 15", + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 15, "damage")); + + private static Task Test_PredatorPlus() + => TestHelpers.SingleCardTest( + name: "Predator+ deals 20", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 20, "damage")); + + // ── Skewer: X-cost. 8 dmg per hit × X (11 dmg upgraded). + // PlayCard now captures CapturedXValue from current energy. With energy=3 + // (the default), Skewer hits 3 times for 8 → 24 dmg. Skewer+ → 33 dmg. + private static Task Test_Skewer() + => TestHelpers.SingleCardTest( + name: "Skewer (X=3): 8 dmg × 3 hits = 24", + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 24, "damage")); + + private static Task Test_SkewerPlus() + => TestHelpers.SingleCardTest( + name: "Skewer+ (X=3): 11 dmg × 3 hits = 33", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 33, "damage")); + + // ── Snakebite: applies 7 Poison to single target (10 upgraded). + private static Task Test_Snakebite() + => TestHelpers.SingleCardTest( + name: "Snakebite applies 7 Poison", + assert: (h, before) => TestHelpers.ExpectPower(h.Dummy, "POISON", expectedAmount: 7)); + + private static Task Test_SnakebitePlus() + => TestHelpers.SingleCardTest( + name: "Snakebite+ applies 10 Poison", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.ExpectPower(h.Dummy, "POISON", expectedAmount: 10)); + + // ── Ricochet: 3 dmg × 4 hits to RANDOM enemies (5 hits upgraded). + // Random target = nondeterministic; only one enemy on the board so practically + // it always hits the dummy, but we shouldn't lock that behavior down. + private static Task Test_Ricochet() + => TestHelpers.Skip("Ricochet (random target)", + "TargetType.RandomEnemy makes targeting nondeterministic; need multi-enemy harness."); + + private static Task Test_RicochetPlus() + => TestHelpers.Skip("Ricochet+ (random target)", + "TargetType.RandomEnemy makes targeting nondeterministic; need multi-enemy harness."); + + // ── StormOfSteel: discard rest-of-hand (after StormOfSteel moves to Play pile), + // gain that many shivs. Preload hand with 3 fillers → 3 shivs. + // Upgraded version creates upgraded shivs. + private static Task Test_StormOfSteel() + => TestHelpers.PreloadHandTest( + name: "StormOfSteel discards 3-card hand → 3 shivs", + handLoadout: new[] { typeof(DefendSilent), typeof(DefendSilent), typeof(DefendSilent) }, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + int shivs = pcs.Hand.Cards.Count(c => c.GetType() == typeof(Shiv)); + if (shivs != 3) return $"expected 3 shivs, got {shivs}"; + if (pcs.Hand.Cards.First(c => c.GetType() == typeof(Shiv)).CurrentUpgradeLevel != 0) + return "expected unupgraded shivs"; + return null; + }); + + private static Task Test_StormOfSteelPlus() + => TestHelpers.PreloadHandTest( + name: "StormOfSteel+ discards 3-card hand → 3 upgraded shivs", + upgradeLevel: 1, + handLoadout: new[] { typeof(DefendSilent), typeof(DefendSilent), typeof(DefendSilent) }, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + int shivs = pcs.Hand.Cards.Count(c => c.GetType() == typeof(Shiv)); + if (shivs != 3) return $"expected 3 shivs, got {shivs}"; + if (pcs.Hand.Cards.First(c => c.GetType() == typeof(Shiv)).CurrentUpgradeLevel < 1) + return "expected upgraded shivs"; + return null; + }); + + // ── Outbreak: applies OutbreakPower 11 (15 upgraded). Self target. Cost 1 (NOT X-cost). + private static Task Test_Outbreak() + => TestHelpers.SingleCardTest( + name: "Outbreak applies OutbreakPower 11", + assert: (h, before) => TestHelpers.ExpectPower(h.Player.Creature, "OUTBREAK", expectedAmount: 11)); + + private static Task Test_OutbreakPlus() + => TestHelpers.SingleCardTest( + name: "Outbreak+ applies OutbreakPower 15", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.ExpectPower(h.Player.Creature, "OUTBREAK", expectedAmount: 15)); + + // ── HiddenDaggers: requires player to pick cards from hand to discard via CardSelectCmd. + // AutoCardSelector picks the first N cards, but with only HiddenDaggers itself in + // hand (and it's already in the Play pile by then), there are 0 candidates and the + // discard count won't match. Skip both — this is fundamentally a hand-interaction card. + private static Task Test_HiddenDaggers() + => TestHelpers.Skip("HiddenDaggers (CardSelectCmd discard)", + "Discards 2 cards chosen from hand; isolated harness has no other hand cards to pick."); + + private static Task Test_HiddenDaggersPlus() + => TestHelpers.Skip("HiddenDaggers+ (CardSelectCmd discard)", + "Discards 2 cards chosen from hand; isolated harness has no other hand cards to pick."); + + // ── HandTrick: 7 block (10 upgraded), then Sly a skill in hand. With no other skills + // in hand the Sly portion silently no-ops; we assert the block delta. + private static Task Test_HandTrick() + => TestHelpers.SingleCardTest( + name: "HandTrick gives 7 block", + assert: (h, before) => TestHelpers.Expect(h.Player.Creature.Block - before.playerBlock, 7, "block")); + + private static Task Test_HandTrickPlus() + => TestHelpers.SingleCardTest( + name: "HandTrick+ gives 10 block", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.Expect(h.Player.Creature.Block - before.playerBlock, 10, "block")); +} diff --git a/src/SilentTests/Bucket3_ConditionalBurst.cs b/src/SilentTests/Bucket3_ConditionalBurst.cs new file mode 100644 index 0000000..3c68f80 --- /dev/null +++ b/src/SilentTests/Bucket3_ConditionalBurst.cs @@ -0,0 +1,314 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using MegaCrit.Sts2.Core.Models.Cards; +using MegaCrit.Sts2.Core.Models.Powers; + +namespace StS2Sim.SilentTests; + +/// +/// Bucket 3 — Conditional/Burst Attacks. Damage scales with combat state: +/// cards played this turn, attacks remaining, enemy HP %, etc. +/// +/// Scope (12 cards × 2 upgrade levels = 24 tests): +/// Finisher, Assassinate, GrandFinale, KnifeTrap, Strangle, PoisonedStab, +/// Expose, Malaise, CorrosiveWave, Suppress, PiercingWail, LegSweep +/// +internal static class Bucket3_ConditionalBurst +{ + public static async Task> RunAll() + { + var results = new List(); + + results.Add(await Test_Finisher()); + results.Add(await Test_FinisherPlus()); + results.Add(await Test_Assassinate()); + results.Add(await Test_AssassinatePlus()); + results.Add(await Test_GrandFinale()); + results.Add(await Test_GrandFinalePlus()); + results.Add(await Test_KnifeTrap()); + results.Add(await Test_KnifeTrapPlus()); + results.Add(await Test_Strangle()); + results.Add(await Test_StranglePlus()); + results.Add(await Test_PoisonedStab()); + results.Add(await Test_PoisonedStabPlus()); + results.Add(await Test_Expose()); + results.Add(await Test_ExposePlus()); + results.Add(await Test_Malaise()); + results.Add(await Test_MalaisePlus()); + results.Add(await Test_CorrosiveWave()); + results.Add(await Test_CorrosiveWavePlus()); + results.Add(await Test_Suppress()); + results.Add(await Test_SuppressPlus()); + results.Add(await Test_PiercingWail()); + results.Add(await Test_PiercingWailPlus()); + results.Add(await Test_LegSweep()); + results.Add(await Test_LegSweepPlus()); + + return results; + } + + // ─── Finisher ──────────────────────────────────────────────────────────── + // Hits N times for 6 (8 upgraded) where N = attacks finished this turn + // (by the same owner). After 2 prior Strikes, expect 2 hits = 12 damage + // (or 16 upgraded). Strike itself does 6, so total damage = 6 + 6 + 12 = 24. + + private static Task Test_Finisher() + => TestHelpers.SequenceTest( + name: "Finisher hits once per attack played this turn (base 6 dmg)", + sequence: new[] + { + new Harness.DeckEntry(typeof(StrikeSilent)), + new Harness.DeckEntry(typeof(StrikeSilent)), + new Harness.DeckEntry(typeof(Finisher)), + }, + energy: 99, + assert: (h, before) => + { + // Strike(6) + Strike(6) + Finisher(6 dmg × 2 attacks already played) = 24 + int dmg = before.dummyHp - h.Dummy.CurrentHp; + return TestHelpers.Expect(dmg, 24, "total damage"); + }); + + private static Task Test_FinisherPlus() + => TestHelpers.SequenceTest( + name: "Finisher+ hits once per attack played this turn (base 8 dmg)", + sequence: new[] + { + new Harness.DeckEntry(typeof(StrikeSilent)), + new Harness.DeckEntry(typeof(StrikeSilent)), + new Harness.DeckEntry(typeof(Finisher), 1), + }, + energy: 99, + assert: (h, before) => + { + // Strike(6) + Strike(6) + Finisher+(8 × 2) = 28 + int dmg = before.dummyHp - h.Dummy.CurrentHp; + return TestHelpers.Expect(dmg, 28, "total damage"); + }); + + // ─── Assassinate ───────────────────────────────────────────────────────── + // 10 dmg + 1 Vulnerable. Upgraded: 13 dmg + 2 Vulnerable. + + private static Task Test_Assassinate() + => TestHelpers.SingleCardTest( + name: "Assassinate deals 10 + 1 Vulnerable", + assert: (h, before) => + { + if (before.dummyHp - h.Dummy.CurrentHp != 10) return $"expected 10 damage, got {before.dummyHp - h.Dummy.CurrentHp}"; + return TestHelpers.ExpectPower(h.Dummy, "VULNERABLE", expectedAmount: 1); + }); + + private static Task Test_AssassinatePlus() + => TestHelpers.SingleCardTest( + name: "Assassinate+ deals 13 + 2 Vulnerable", + upgradeLevel: 1, + assert: (h, before) => + { + if (before.dummyHp - h.Dummy.CurrentHp != 13) return $"expected 13 damage, got {before.dummyHp - h.Dummy.CurrentHp}"; + return TestHelpers.ExpectPower(h.Dummy, "VULNERABLE", expectedAmount: 2); + }); + + // ─── GrandFinale ───────────────────────────────────────────────────────── + // 60 dmg AOE only when draw pile is empty. Upgraded: 75. + // SingleCardTest with no extras puts the only card (GrandFinale) in hand, + // leaving the draw pile empty — so it should be playable. + + private static Task Test_GrandFinale() + => TestHelpers.SingleCardTest( + name: "GrandFinale deals 60 AOE when draw pile is empty", + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 60, "damage")); + + private static Task Test_GrandFinalePlus() + => TestHelpers.SingleCardTest( + name: "GrandFinale+ deals 75 AOE when draw pile is empty", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 75, "damage")); + + // ─── KnifeTrap ─────────────────────────────────────────────────────────── + // Re-plays every Shiv currently in your Exhaust pile at the target. + // With no Shivs in exhaust, deals 0 damage and applies nothing. + // We test this minimal case since seeding the exhaust pile would require + // more harness machinery than SingleCardTest exposes. + + private static Task Test_KnifeTrap() + => TestHelpers.SingleCardTest( + name: "KnifeTrap with no shivs in exhaust deals 0", + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 0, "damage")); + + private static Task Test_KnifeTrapPlus() + => TestHelpers.SingleCardTest( + name: "KnifeTrap+ with no shivs in exhaust deals 0", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 0, "damage")); + + // ─── Strangle ──────────────────────────────────────────────────────────── + // 8 dmg + applies StranglePower(2). Upgraded: 10 dmg + StranglePower(3). + // StranglePower itself triggers on next card play to deal counter damage, + // but we just verify the immediate hit + power application here. + + private static Task Test_Strangle() + => TestHelpers.SingleCardTest( + name: "Strangle deals 8 + applies StranglePower(2)", + assert: (h, before) => + { + if (before.dummyHp - h.Dummy.CurrentHp != 8) return $"expected 8 damage, got {before.dummyHp - h.Dummy.CurrentHp}"; + return TestHelpers.ExpectPower(h.Dummy, "STRANGLE", expectedAmount: 2); + }); + + private static Task Test_StranglePlus() + => TestHelpers.SingleCardTest( + name: "Strangle+ deals 10 + applies StranglePower(3)", + upgradeLevel: 1, + assert: (h, before) => + { + if (before.dummyHp - h.Dummy.CurrentHp != 10) return $"expected 10 damage, got {before.dummyHp - h.Dummy.CurrentHp}"; + return TestHelpers.ExpectPower(h.Dummy, "STRANGLE", expectedAmount: 3); + }); + + // ─── PoisonedStab ──────────────────────────────────────────────────────── + // 6 dmg + 3 Poison. Upgraded: 8 dmg + 4 Poison. + + private static Task Test_PoisonedStab() + => TestHelpers.SingleCardTest( + name: "PoisonedStab deals 6 + 3 Poison", + assert: (h, before) => + { + if (before.dummyHp - h.Dummy.CurrentHp != 6) return $"expected 6 damage, got {before.dummyHp - h.Dummy.CurrentHp}"; + return TestHelpers.ExpectPower(h.Dummy, "POISON", expectedAmount: 3); + }); + + private static Task Test_PoisonedStabPlus() + => TestHelpers.SingleCardTest( + name: "PoisonedStab+ deals 8 + 4 Poison", + upgradeLevel: 1, + assert: (h, before) => + { + if (before.dummyHp - h.Dummy.CurrentHp != 8) return $"expected 8 damage, got {before.dummyHp - h.Dummy.CurrentHp}"; + return TestHelpers.ExpectPower(h.Dummy, "POISON", expectedAmount: 4); + }); + + // ─── Expose ────────────────────────────────────────────────────────────── + // Removes target's block, removes Artifact, applies 2 Vulnerable. + // Upgraded: applies 3 Vulnerable. Dummy has 0 block / no artifact by + // default, so just verify Vulnerable application (no damage). + + private static Task Test_Expose() + => TestHelpers.SingleCardTest( + name: "Expose applies 2 Vulnerable", + assert: (h, before) => + { + if (before.dummyHp != h.Dummy.CurrentHp) return $"expected no damage, got {before.dummyHp - h.Dummy.CurrentHp}"; + return TestHelpers.ExpectPower(h.Dummy, "VULNERABLE", expectedAmount: 2); + }); + + private static Task Test_ExposePlus() + => TestHelpers.SingleCardTest( + name: "Expose+ applies 3 Vulnerable", + upgradeLevel: 1, + assert: (h, before) => + { + if (before.dummyHp != h.Dummy.CurrentHp) return $"expected no damage, got {before.dummyHp - h.Dummy.CurrentHp}"; + return TestHelpers.ExpectPower(h.Dummy, "VULNERABLE", expectedAmount: 3); + }); + + // ─── Malaise ───────────────────────────────────────────────────────────── + // X-cost: applies -X Strength + X Weak (X+1 each upgraded). + // Malaise: X-cost. Apply -X Strength + X Weak to enemy. Upgraded: -X-1 / X+1. + // PlayCard now captures CapturedXValue from current energy (3 by default) → X=3. + private static Task Test_Malaise() + => TestHelpers.SingleCardTest( + name: "Malaise (X=3): -3 Strength + 3 Weak on enemy", + assert: (h, before) => + { + var weak = TestHelpers.ExpectPower(h.Dummy, "WEAK", expectedAmount: 3); + if (weak != null) return weak; + // Strength can be negative. ExpectPower with negative amount works fine. + return TestHelpers.ExpectPower(h.Dummy, "STRENGTH", expectedAmount: -3); + }); + + private static Task Test_MalaisePlus() + => TestHelpers.SingleCardTest( + name: "Malaise+ (X=3): -4 Strength + 4 Weak on enemy", + upgradeLevel: 1, + assert: (h, before) => + { + var weak = TestHelpers.ExpectPower(h.Dummy, "WEAK", expectedAmount: 4); + if (weak != null) return weak; + return TestHelpers.ExpectPower(h.Dummy, "STRENGTH", expectedAmount: -4); + }); + + // ─── CorrosiveWave ─────────────────────────────────────────────────────── + // Applies CorrosiveWavePower(2) to self. Upgraded: 3. + + private static Task Test_CorrosiveWave() + => TestHelpers.SingleCardTest( + name: "CorrosiveWave applies CorrosiveWavePower(2) to self", + assert: (h, before) => TestHelpers.ExpectPower(h.Player.Creature, "CORROSIVE", expectedAmount: 2)); + + private static Task Test_CorrosiveWavePlus() + => TestHelpers.SingleCardTest( + name: "CorrosiveWave+ applies CorrosiveWavePower(3) to self", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.ExpectPower(h.Player.Creature, "CORROSIVE", expectedAmount: 3)); + + // ─── Suppress ──────────────────────────────────────────────────────────── + // 11 dmg + 3 Weak. Upgraded: 17 dmg + 5 Weak. + + private static Task Test_Suppress() + => TestHelpers.SingleCardTest( + name: "Suppress deals 11 + 3 Weak", + assert: (h, before) => + { + if (before.dummyHp - h.Dummy.CurrentHp != 11) return $"expected 11 damage, got {before.dummyHp - h.Dummy.CurrentHp}"; + return TestHelpers.ExpectPower(h.Dummy, "WEAK", expectedAmount: 3); + }); + + private static Task Test_SuppressPlus() + => TestHelpers.SingleCardTest( + name: "Suppress+ deals 17 + 5 Weak", + upgradeLevel: 1, + assert: (h, before) => + { + if (before.dummyHp - h.Dummy.CurrentHp != 17) return $"expected 17 damage, got {before.dummyHp - h.Dummy.CurrentHp}"; + return TestHelpers.ExpectPower(h.Dummy, "WEAK", expectedAmount: 5); + }); + + // ─── PiercingWail ──────────────────────────────────────────────────────── + // Applies PiercingWailPower(6) to all enemies. Upgraded: 8. + // Single dummy, so only one application to verify. + + private static Task Test_PiercingWail() + => TestHelpers.SingleCardTest( + name: "PiercingWail applies PiercingWailPower(6) to enemy", + assert: (h, before) => TestHelpers.ExpectPower(h.Dummy, "PIERCING", expectedAmount: 6)); + + private static Task Test_PiercingWailPlus() + => TestHelpers.SingleCardTest( + name: "PiercingWail+ applies PiercingWailPower(8) to enemy", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.ExpectPower(h.Dummy, "PIERCING", expectedAmount: 8)); + + // ─── LegSweep ──────────────────────────────────────────────────────────── + // 11 block (self) + 2 Weak (target). Upgraded: 14 block + 3 Weak. + + private static Task Test_LegSweep() + => TestHelpers.SingleCardTest( + name: "LegSweep grants 11 block + applies 2 Weak", + assert: (h, before) => + { + int blockGain = h.Player.Creature.Block - before.playerBlock; + if (blockGain != 11) return $"expected 11 block, got {blockGain}"; + return TestHelpers.ExpectPower(h.Dummy, "WEAK", expectedAmount: 2); + }); + + private static Task Test_LegSweepPlus() + => TestHelpers.SingleCardTest( + name: "LegSweep+ grants 14 block + applies 3 Weak", + upgradeLevel: 1, + assert: (h, before) => + { + int blockGain = h.Player.Creature.Block - before.playerBlock; + if (blockGain != 14) return $"expected 14 block, got {blockGain}"; + return TestHelpers.ExpectPower(h.Dummy, "WEAK", expectedAmount: 3); + }); +} diff --git a/src/SilentTests/Bucket4_DefensiveDraw.cs b/src/SilentTests/Bucket4_DefensiveDraw.cs new file mode 100644 index 0000000..1d51e9b --- /dev/null +++ b/src/SilentTests/Bucket4_DefensiveDraw.cs @@ -0,0 +1,385 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using MegaCrit.Sts2.Core.Models.Cards; + +namespace StS2Sim.SilentTests; + +/// +/// Bucket 4 — Defensive & Draw Skills. Block, draw, retain, dodge. +/// Mostly fits SingleCardTest<T> with delta assertions on Block / draw pile size. +/// +/// Scope (15 cards × 2 upgrade levels = 30 tests): +/// DefendSilent, Acrobatics, Backflip, Deflect, DodgeAndRoll, Prepared, +/// Reflex, Survivor, EscapePlan, ShadowStep, Blur, Dash, Tactician, +/// Anticipate, Tracking +/// +internal static class Bucket4_DefensiveDraw +{ + public static async Task> RunAll() + { + var results = new List(); + + results.Add(await Test_DefendSilent()); + results.Add(await Test_DefendSilentPlus()); + results.Add(await Test_Acrobatics()); + results.Add(await Test_AcrobaticsPlus()); + results.Add(await Test_Backflip()); + results.Add(await Test_BackflipPlus()); + results.Add(await Test_Deflect()); + results.Add(await Test_DeflectPlus()); + results.Add(await Test_DodgeAndRoll()); + results.Add(await Test_DodgeAndRollPlus()); + results.Add(await Test_Prepared()); + results.Add(await Test_PreparedPlus()); + results.Add(await Test_Reflex()); + results.Add(await Test_ReflexPlus()); + results.Add(await Test_Survivor()); + results.Add(await Test_SurvivorPlus()); + results.Add(await Test_EscapePlan()); + results.Add(await Test_EscapePlanPlus()); + results.Add(await Test_ShadowStep()); + results.Add(await Test_ShadowStepPlus()); + results.Add(await Test_Blur()); + results.Add(await Test_BlurPlus()); + results.Add(await Test_Dash()); + results.Add(await Test_DashPlus()); + results.Add(await Test_Tactician()); + results.Add(await Test_TacticianPlus()); + results.Add(await Test_Anticipate()); + results.Add(await Test_AnticipatePlus()); + results.Add(await Test_Tracking()); + results.Add(await Test_TrackingPlus()); + + return results; + } + + // ─── DefendSilent — base 5 block, +3 → 8 upgraded ───────────────────────── + private static Task Test_DefendSilent() + => TestHelpers.SingleCardTest( + name: "DefendSilent grants 5 block", + assert: (h, before) => TestHelpers.Expect( + h.Player.Creature.Block - before.playerBlock, 5, "block")); + + private static Task Test_DefendSilentPlus() + => TestHelpers.SingleCardTest( + name: "DefendSilent+ grants 8 block", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.Expect( + h.Player.Creature.Block - before.playerBlock, 8, "block")); + + // ─── Acrobatics — draws 3 (4+), discards 1 ─────────────────────────────── + private static Task Test_Acrobatics() + => TestHelpers.SingleCardTest( + name: "Acrobatics draws 3, discards 1", + extraDeckCards: new[] { typeof(StrikeSilent), typeof(StrikeSilent), typeof(StrikeSilent), typeof(StrikeSilent) }, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + int drawn = before.drawSize - pcs.DrawPile.Cards.Count; + if (drawn != 3) return $"expected 3 cards drawn, got {drawn}"; + int discarded = pcs.DiscardPile.Cards.Count - before.discardSize; + // Discard pile gains 1 from the auto-discard, plus 1 for the played Acrobatics itself. + if (discarded < 1) return $"expected at least 1 discarded, got {discarded}"; + return null; + }); + + private static Task Test_AcrobaticsPlus() + => TestHelpers.SingleCardTest( + name: "Acrobatics+ draws 4, discards 1", + upgradeLevel: 1, + extraDeckCards: new[] { typeof(StrikeSilent), typeof(StrikeSilent), typeof(StrikeSilent), typeof(StrikeSilent), typeof(StrikeSilent) }, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + int drawn = before.drawSize - pcs.DrawPile.Cards.Count; + if (drawn != 4) return $"expected 4 cards drawn, got {drawn}"; + return null; + }); + + // ─── Backflip — 5 block (8+) and draws 2 ───────────────────────────────── + private static Task Test_Backflip() + => TestHelpers.SingleCardTest( + name: "Backflip grants 5 block + draws 2", + extraDeckCards: new[] { typeof(StrikeSilent), typeof(StrikeSilent), typeof(StrikeSilent) }, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + int blockGained = h.Player.Creature.Block - before.playerBlock; + if (blockGained != 5) return $"expected 5 block, got {blockGained}"; + int drawn = before.drawSize - pcs.DrawPile.Cards.Count; + if (drawn != 2) return $"expected 2 drawn, got {drawn}"; + return null; + }); + + private static Task Test_BackflipPlus() + => TestHelpers.SingleCardTest( + name: "Backflip+ grants 8 block + draws 2", + upgradeLevel: 1, + extraDeckCards: new[] { typeof(StrikeSilent), typeof(StrikeSilent), typeof(StrikeSilent) }, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + int blockGained = h.Player.Creature.Block - before.playerBlock; + if (blockGained != 8) return $"expected 8 block, got {blockGained}"; + int drawn = before.drawSize - pcs.DrawPile.Cards.Count; + if (drawn != 2) return $"expected 2 drawn, got {drawn}"; + return null; + }); + + // ─── Deflect — 4 block (7+), 0 cost ────────────────────────────────────── + private static Task Test_Deflect() + => TestHelpers.SingleCardTest( + name: "Deflect grants 4 block", + assert: (h, before) => TestHelpers.Expect( + h.Player.Creature.Block - before.playerBlock, 4, "block")); + + private static Task Test_DeflectPlus() + => TestHelpers.SingleCardTest( + name: "Deflect+ grants 7 block", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.Expect( + h.Player.Creature.Block - before.playerBlock, 7, "block")); + + // ─── DodgeAndRoll — 4 block (6+) plus BlockNextTurnPower ───────────────── + private static Task Test_DodgeAndRoll() + => TestHelpers.SingleCardTest( + name: "DodgeAndRoll grants 4 block + BlockNextTurn", + assert: (h, before) => + { + int blockGained = h.Player.Creature.Block - before.playerBlock; + if (blockGained != 4) return $"expected 4 block, got {blockGained}"; + return TestHelpers.ExpectPower(h.Player.Creature, "BLOCK_NEXT_TURN", expectedAmount: 4); + }); + + private static Task Test_DodgeAndRollPlus() + => TestHelpers.SingleCardTest( + name: "DodgeAndRoll+ grants 6 block + BlockNextTurn", + upgradeLevel: 1, + assert: (h, before) => + { + int blockGained = h.Player.Creature.Block - before.playerBlock; + if (blockGained != 6) return $"expected 6 block, got {blockGained}"; + return TestHelpers.ExpectPower(h.Player.Creature, "BLOCK_NEXT_TURN", expectedAmount: 6); + }); + + // ─── Prepared — 0 cost, draw 1 (2+) discard 1 (2+) ─────────────────────── + private static Task Test_Prepared() + => TestHelpers.SingleCardTest( + name: "Prepared draws 1", + extraDeckCards: new[] { typeof(StrikeSilent), typeof(StrikeSilent) }, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + int drawn = before.drawSize - pcs.DrawPile.Cards.Count; + return TestHelpers.Expect(drawn, 1, "drawn"); + }); + + private static Task Test_PreparedPlus() + => TestHelpers.SingleCardTest( + name: "Prepared+ draws 2", + upgradeLevel: 1, + extraDeckCards: new[] { typeof(StrikeSilent), typeof(StrikeSilent), typeof(StrikeSilent), typeof(StrikeSilent) }, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + int drawn = before.drawSize - pcs.DrawPile.Cards.Count; + return TestHelpers.Expect(drawn, 2, "drawn"); + }); + + // ─── Reflex — 3 cost, draws 2 (3+). Sly trigger isn't tested via OnPlay ── + private static Task Test_Reflex() + => TestHelpers.SingleCardTest( + name: "Reflex draws 2", + extraDeckCards: new[] { typeof(StrikeSilent), typeof(StrikeSilent), typeof(StrikeSilent) }, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + int drawn = before.drawSize - pcs.DrawPile.Cards.Count; + return TestHelpers.Expect(drawn, 2, "drawn"); + }); + + private static Task Test_ReflexPlus() + => TestHelpers.SingleCardTest( + name: "Reflex+ draws 3", + upgradeLevel: 1, + extraDeckCards: new[] { typeof(StrikeSilent), typeof(StrikeSilent), typeof(StrikeSilent), typeof(StrikeSilent) }, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + int drawn = before.drawSize - pcs.DrawPile.Cards.Count; + return TestHelpers.Expect(drawn, 3, "drawn"); + }); + + // ─── Survivor — 8 block (11+) + discard 1 ──────────────────────────────── + private static Task Test_Survivor() + => TestHelpers.SingleCardTest( + name: "Survivor grants 8 block", + assert: (h, before) => TestHelpers.Expect( + h.Player.Creature.Block - before.playerBlock, 8, "block")); + + private static Task Test_SurvivorPlus() + => TestHelpers.SingleCardTest( + name: "Survivor+ grants 11 block", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.Expect( + h.Player.Creature.Block - before.playerBlock, 11, "block")); + + // ─── EscapePlan — draw 1; if Skill, +3 block (5+) ──────────────────────── + // Use a Defend in the draw pile to guarantee the drawn card is a Skill. + private static Task Test_EscapePlan() + => TestHelpers.SingleCardTest( + name: "EscapePlan draws Skill -> 3 block", + extraDeckCards: new[] { typeof(DefendSilent) }, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + int drawn = before.drawSize - pcs.DrawPile.Cards.Count; + if (drawn != 1) return $"expected 1 drawn, got {drawn}"; + int blockGained = h.Player.Creature.Block - before.playerBlock; + return TestHelpers.Expect(blockGained, 3, "block"); + }); + + private static Task Test_EscapePlanPlus() + => TestHelpers.SingleCardTest( + name: "EscapePlan+ draws Skill -> 5 block", + upgradeLevel: 1, + extraDeckCards: new[] { typeof(DefendSilent) }, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + int drawn = before.drawSize - pcs.DrawPile.Cards.Count; + if (drawn != 1) return $"expected 1 drawn, got {drawn}"; + int blockGained = h.Player.Creature.Block - before.playerBlock; + return TestHelpers.Expect(blockGained, 5, "block"); + }); + + // ─── ShadowStep — discard hand, apply ShadowStepPower 1 ────────────────── + // Use PreloadHandTest so we have cards in hand to discard. + private static Task Test_ShadowStep() + => TestHelpers.PreloadHandTest( + name: "ShadowStep discards hand + applies ShadowStep power", + handLoadout: new[] { typeof(StrikeSilent), typeof(StrikeSilent) }, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + // Hand should be empty (the played card is gone, plus the 2 fodder were discarded). + if (pcs.Hand.Cards.Count != 0) + return $"expected empty hand, got {pcs.Hand.Cards.Count}"; + return TestHelpers.ExpectPower(h.Player.Creature, "SHADOW_STEP", expectedAmount: 1); + }); + + private static Task Test_ShadowStepPlus() + => TestHelpers.PreloadHandTest( + name: "ShadowStep+ discards hand + applies ShadowStep power (0 cost)", + upgradeLevel: 1, + handLoadout: new[] { typeof(StrikeSilent), typeof(StrikeSilent) }, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + if (pcs.Hand.Cards.Count != 0) + return $"expected empty hand, got {pcs.Hand.Cards.Count}"; + return TestHelpers.ExpectPower(h.Player.Creature, "SHADOW_STEP", expectedAmount: 1); + }); + + // ─── Blur — 5 block (8+) + Blur power 1 ────────────────────────────────── + private static Task Test_Blur() + => TestHelpers.SingleCardTest( + name: "Blur grants 5 block + Blur power", + assert: (h, before) => + { + int blockGained = h.Player.Creature.Block - before.playerBlock; + if (blockGained != 5) return $"expected 5 block, got {blockGained}"; + return TestHelpers.ExpectPower(h.Player.Creature, "BLUR", expectedAmount: 1); + }); + + private static Task Test_BlurPlus() + => TestHelpers.SingleCardTest( + name: "Blur+ grants 8 block + Blur power", + upgradeLevel: 1, + assert: (h, before) => + { + int blockGained = h.Player.Creature.Block - before.playerBlock; + if (blockGained != 8) return $"expected 8 block, got {blockGained}"; + return TestHelpers.ExpectPower(h.Player.Creature, "BLUR", expectedAmount: 1); + }); + + // ─── Dash — 10 dmg + 10 block (13/13 upgraded) ─────────────────────────── + private static Task Test_Dash() + => TestHelpers.SingleCardTest( + name: "Dash deals 10 + 10 block", + assert: (h, before) => + { + int dmg = before.dummyHp - h.Dummy.CurrentHp; + if (dmg != 10) return $"expected 10 damage, got {dmg}"; + int blockGained = h.Player.Creature.Block - before.playerBlock; + return TestHelpers.Expect(blockGained, 10, "block"); + }); + + private static Task Test_DashPlus() + => TestHelpers.SingleCardTest( + name: "Dash+ deals 13 + 13 block", + upgradeLevel: 1, + assert: (h, before) => + { + int dmg = before.dummyHp - h.Dummy.CurrentHp; + if (dmg != 13) return $"expected 13 damage, got {dmg}"; + int blockGained = h.Player.Creature.Block - before.playerBlock; + return TestHelpers.Expect(blockGained, 13, "block"); + }); + + // ─── Tactician — gains 1 energy (2+) on play (3 cost so net -2 / -1) ───── + // Set energy high enough to pay the 3 cost; assert energy delta after play. + private static Task Test_Tactician() + => TestHelpers.SingleCardTest( + name: "Tactician gains 1 energy on play", + energy: 5, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + // before.energy was 5; play cost = 3, then GainEnergy(1) -> 5 - 3 + 1 = 3. + int delta = pcs.Energy - before.energy; + return TestHelpers.Expect(delta, -2, "energy delta (cost 3 minus gain 1)"); + }); + + private static Task Test_TacticianPlus() + => TestHelpers.SingleCardTest( + name: "Tactician+ gains 2 energy on play", + upgradeLevel: 1, + energy: 5, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + // 5 - 3 + 2 = 4 -> delta -1. + int delta = pcs.Energy - before.energy; + return TestHelpers.Expect(delta, -1, "energy delta (cost 3 minus gain 2)"); + }); + + // ─── Anticipate — applies AnticipatePower 2 (3+) ───────────────────────── + private static Task Test_Anticipate() + => TestHelpers.SingleCardTest( + name: "Anticipate applies Anticipate 2", + assert: (h, before) => + TestHelpers.ExpectPower(h.Player.Creature, "ANTICIPATE", expectedAmount: 2)); + + private static Task Test_AnticipatePlus() + => TestHelpers.SingleCardTest( + name: "Anticipate+ applies Anticipate 3", + upgradeLevel: 1, + assert: (h, before) => + TestHelpers.ExpectPower(h.Player.Creature, "ANTICIPATE", expectedAmount: 3)); + + // ─── Tracking — applies TrackingPower 2 (upgrade only reduces cost) ───── + private static Task Test_Tracking() + => TestHelpers.SingleCardTest( + name: "Tracking applies Tracking 2", + assert: (h, before) => + TestHelpers.ExpectPower(h.Player.Creature, "TRACKING", expectedAmount: 2)); + + private static Task Test_TrackingPlus() + => TestHelpers.SingleCardTest( + name: "Tracking+ applies Tracking 2 (cost reduced to 1)", + upgradeLevel: 1, + assert: (h, before) => + TestHelpers.ExpectPower(h.Player.Creature, "TRACKING", expectedAmount: 2)); +} diff --git a/src/SilentTests/Bucket5_UtilityCardManip.cs b/src/SilentTests/Bucket5_UtilityCardManip.cs new file mode 100644 index 0000000..0b52b99 --- /dev/null +++ b/src/SilentTests/Bucket5_UtilityCardManip.cs @@ -0,0 +1,416 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MegaCrit.Sts2.Core.Models.Cards; +using MegaCrit.Sts2.Core.Models.Powers; + +namespace StS2Sim.SilentTests; + +/// +/// Bucket 5 — Utility & Card Manipulation. Discard, search, choose-from-hand, +/// X-cost. Highest crash-rate bucket — these touch the most game APIs. +/// +/// Scope (16 cards × 2 upgrade levels = 32 tests): +/// Adrenaline, Expertise, ToolsOfTheTrade, CalculatedGamble, MasterPlanner, +/// WellLaidPlans, Nightmare, Pinpoint, UpMySleeve, Abrasive, Accelerant, +/// Mirage, Haze, Speedster, Shadowmeld, TheHunt +/// +internal static class Bucket5_UtilityCardManip +{ + public static async Task> RunAll() + { + var results = new List(); + + results.Add(await Test_Adrenaline()); + results.Add(await Test_AdrenalinePlus()); + results.Add(await Test_Expertise()); + results.Add(await Test_ExpertisePlus()); + results.Add(await Test_ToolsOfTheTrade()); + results.Add(await Test_ToolsOfTheTradePlus()); + results.Add(await Test_CalculatedGamble()); + results.Add(await Test_CalculatedGamblePlus()); + results.Add(await Test_MasterPlanner()); + results.Add(await Test_MasterPlannerPlus()); + results.Add(await Test_WellLaidPlans()); + results.Add(await Test_WellLaidPlansPlus()); + results.Add(await Test_Nightmare()); + results.Add(await Test_NightmarePlus()); + results.Add(await Test_Pinpoint()); + results.Add(await Test_PinpointPlus()); + results.Add(await Test_UpMySleeve()); + results.Add(await Test_UpMySleevePlus()); + results.Add(await Test_Abrasive()); + results.Add(await Test_AbrasivePlus()); + results.Add(await Test_Accelerant()); + results.Add(await Test_AccelerantPlus()); + results.Add(await Test_Mirage()); + results.Add(await Test_MiragePlus()); + results.Add(await Test_Haze()); + results.Add(await Test_HazePlus()); + results.Add(await Test_Speedster()); + results.Add(await Test_SpeedsterPlus()); + results.Add(await Test_Shadowmeld()); + results.Add(await Test_ShadowmeldPlus()); + results.Add(await Test_TheHunt()); + results.Add(await Test_TheHuntPlus()); + + return results; + } + + // ─── Adrenaline ────────────────────────────────────────────────────────── + // Base: gain 1 Energy, draw 2. Upgrade: gain 2 Energy, draw 2. Exhaust. + + private static Task Test_Adrenaline() + => TestHelpers.SingleCardTest( + name: "Adrenaline gains 1 energy, draws 2", + energy: 3, + extraDeckCards: new[] { typeof(DefendSilent), typeof(DefendSilent), typeof(DefendSilent) }, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + var energyDelta = pcs.Energy - before.energy; + if (energyDelta != 1) return $"expected +1 energy, got {energyDelta}"; + // Hand started with [Adrenaline] (1 card). Card removed by play (-> 0), + // then draw 2 from draw pile. Final hand = 2. Delta from before = +1. + var handDelta = pcs.Hand.Cards.Count - before.handSize; + if (handDelta != 1) return $"expected hand delta=+1, got {handDelta}"; + return null; + }); + + private static Task Test_AdrenalinePlus() + => TestHelpers.SingleCardTest( + name: "Adrenaline+ gains 2 energy, draws 2", + upgradeLevel: 1, + energy: 3, + extraDeckCards: new[] { typeof(DefendSilent), typeof(DefendSilent), typeof(DefendSilent) }, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + var energyDelta = pcs.Energy - before.energy; + if (energyDelta != 2) return $"expected +2 energy, got {energyDelta}"; + var handDelta = pcs.Hand.Cards.Count - before.handSize; + if (handDelta != 1) return $"expected hand delta=+1, got {handDelta}"; + return null; + }); + + // ─── Expertise ─────────────────────────────────────────────────────────── + // Draw cards until you have 6 in hand (7 upgraded). + + private static Task Test_Expertise() + => TestHelpers.SingleCardTest( + name: "Expertise draws until 6 in hand", + extraDeckCards: Enumerable.Repeat(typeof(DefendSilent), 8).ToArray(), + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + if (pcs.Hand.Cards.Count != 6) + return $"expected hand count=6, got {pcs.Hand.Cards.Count}"; + return null; + }); + + private static Task Test_ExpertisePlus() + => TestHelpers.SingleCardTest( + name: "Expertise+ draws until 7 in hand", + upgradeLevel: 1, + extraDeckCards: Enumerable.Repeat(typeof(DefendSilent), 9).ToArray(), + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + if (pcs.Hand.Cards.Count != 7) + return $"expected hand count=7, got {pcs.Hand.Cards.Count}"; + return null; + }); + + // ─── ToolsOfTheTrade ───────────────────────────────────────────────────── + // Power. Applies ToolsOfTheTradePower 1. Upgrade reduces cost by 1. + + private static Task Test_ToolsOfTheTrade() + => TestHelpers.SingleCardTest( + name: "ToolsOfTheTrade applies power", + assert: (h, before) => + TestHelpers.ExpectPower(h.Player.Creature, "TOOLS")); + + private static Task Test_ToolsOfTheTradePlus() + => TestHelpers.SingleCardTest( + name: "ToolsOfTheTrade+ applies power (cost reduced)", + upgradeLevel: 1, + assert: (h, before) => + TestHelpers.ExpectPower(h.Player.Creature, "TOOLS")); + + // ─── CalculatedGamble ──────────────────────────────────────────────────── + // Discard your hand, draw same number. Exhaust. Upgrade adds Retain. + + // CalculatedGamble: discard hand, draw same number. Test by putting Defends in + // hand and Strikes in draw pile — after play, hand should be Strikes (drawn from + // pile), and discard should hold the Defends. This makes the swap observable + // (a same-card test is invisible because cards reshuffle back in). + private static async Task Test_CalculatedGamble() + { + var h = Harness.BeginCombat(new List + { + new(typeof(CalculatedGamble)), + new(typeof(DefendSilent)), new(typeof(DefendSilent)), + new(typeof(DefendSilent)), new(typeof(DefendSilent)), + new(typeof(StrikeSilent)), new(typeof(StrikeSilent)), + new(typeof(StrikeSilent)), new(typeof(StrikeSilent)), + }, shuffleSeed: 1); + try + { + var pcs = h.Player.PlayerCombatState!; + Reflect.SetEnergy(pcs, 3); + // Hand = [CalcGamble + 4 Defends]. Draw pile = [4 Strikes]. + var calc = pcs.DrawPile.Cards.OfType().First(); + pcs.DrawPile.RemoveInternal(calc); pcs.Hand.AddInternal(calc); + foreach (var c in pcs.DrawPile.Cards.OfType().ToList()) + { pcs.DrawPile.RemoveInternal(c); pcs.Hand.AddInternal(c); } + + await TestHelpers.PlayCard(h, calc); + + // Expect: 4 Defends in discard, 4 Strikes in hand, CalcGamble exhausted. + var defendsDiscarded = pcs.DiscardPile.Cards.Count(c => c is DefendSilent); + var strikesInHand = pcs.Hand.Cards.Count(c => c is StrikeSilent); + if (defendsDiscarded != 4) + return new TestHelpers.TestResult("CalculatedGamble discards 4 Defends, draws 4 Strikes", + TestHelpers.Outcome.Fail, $"expected 4 Defends in discard, got {defendsDiscarded}"); + if (strikesInHand != 4) + return new TestHelpers.TestResult("CalculatedGamble discards 4 Defends, draws 4 Strikes", + TestHelpers.Outcome.Fail, $"expected 4 Strikes in hand, got {strikesInHand}"); + return new TestHelpers.TestResult("CalculatedGamble discards 4 Defends, draws 4 Strikes", + TestHelpers.Outcome.Pass); + } + finally { Harness.EndCombat(); } + } + + // CalculatedGamble+: same effect, plus the Retain keyword. Verify by checking + // the keyword is present on a freshly upgraded card. + private static async Task Test_CalculatedGamblePlus() + { + var h = Harness.BeginCombat(new List + { + new(typeof(CalculatedGamble), 1), + }, shuffleSeed: 1); + try + { + var calc = h.Player.PlayerCombatState!.DrawPile.Cards.OfType().First(); + return calc.Keywords.Contains(MegaCrit.Sts2.Core.Entities.Cards.CardKeyword.Retain) + ? new TestHelpers.TestResult("CalculatedGamble+ has Retain keyword", TestHelpers.Outcome.Pass) + : new TestHelpers.TestResult("CalculatedGamble+ has Retain keyword", TestHelpers.Outcome.Fail, + $"expected Retain keyword, got [{string.Join(",", calc.Keywords)}]"); + } + finally { Harness.EndCombat(); } + } + + // ─── MasterPlanner ─────────────────────────────────────────────────────── + + private static Task Test_MasterPlanner() + => TestHelpers.SingleCardTest( + name: "MasterPlanner applies power", + assert: (h, before) => + TestHelpers.ExpectPower(h.Player.Creature, "PLANNER")); + + private static Task Test_MasterPlannerPlus() + => TestHelpers.SingleCardTest( + name: "MasterPlanner+ applies power (cost reduced)", + upgradeLevel: 1, + assert: (h, before) => + TestHelpers.ExpectPower(h.Player.Creature, "PLANNER")); + + // ─── WellLaidPlans ─────────────────────────────────────────────────────── + // Power. Applies WellLaidPlansPower with RetainAmount 1 (or 2 upgraded). + + private static Task Test_WellLaidPlans() + => TestHelpers.SingleCardTest( + name: "WellLaidPlans applies power (1)", + assert: (h, before) => + TestHelpers.ExpectPower(h.Player.Creature, "PLANS", expectedAmount: 1)); + + private static Task Test_WellLaidPlansPlus() + => TestHelpers.SingleCardTest( + name: "WellLaidPlans+ applies power (2)", + upgradeLevel: 1, + assert: (h, before) => + TestHelpers.ExpectPower(h.Player.Creature, "PLANS", expectedAmount: 2)); + + // ─── Nightmare ─────────────────────────────────────────────────────────── + // Choose a card from hand, apply NightmarePower with that card. Depends on + // AutoCardSelector.FromHand behavior — may crash. + + private static Task Test_Nightmare() + => TestHelpers.PreloadHandTest( + name: "Nightmare picks card and applies power", + handLoadout: new[] { typeof(DefendSilent) }, + assert: (h, before) => + TestHelpers.ExpectPower(h.Player.Creature, "NIGHTMARE")); + + private static Task Test_NightmarePlus() + => TestHelpers.PreloadHandTest( + name: "Nightmare+ picks card and applies power (cost reduced)", + upgradeLevel: 1, + handLoadout: new[] { typeof(DefendSilent) }, + assert: (h, before) => + TestHelpers.ExpectPower(h.Player.Creature, "NIGHTMARE")); + + // ─── Pinpoint ──────────────────────────────────────────────────────────── + // 15 dmg / 19 dmg upgraded. + + private static Task Test_Pinpoint() + => TestHelpers.SingleCardTest( + name: "Pinpoint deals 15", + assert: (h, before) => + TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 15, "damage")); + + private static Task Test_PinpointPlus() + => TestHelpers.SingleCardTest( + name: "Pinpoint+ deals 19", + upgradeLevel: 1, + assert: (h, before) => + TestHelpers.Expect(before.dummyHp - h.Dummy.CurrentHp, 19, "damage")); + + // ─── UpMySleeve ────────────────────────────────────────────────────────── + // Adds 3 (or 4 upgraded) Shivs to hand. + + private static Task Test_UpMySleeve() + => TestHelpers.SingleCardTest( + name: "UpMySleeve adds 3 Shivs to hand", + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + var shivCount = pcs.Hand.Cards.OfType().Count(); + return TestHelpers.Expect(shivCount, 3, "shivs in hand"); + }); + + private static Task Test_UpMySleevePlus() + => TestHelpers.SingleCardTest( + name: "UpMySleeve+ adds 4 Shivs to hand", + upgradeLevel: 1, + assert: (h, before) => + { + var pcs = h.Player.PlayerCombatState!; + var shivCount = pcs.Hand.Cards.OfType().Count(); + return TestHelpers.Expect(shivCount, 4, "shivs in hand"); + }); + + // ─── Abrasive ──────────────────────────────────────────────────────────── + // Power: Dexterity 1, Thorns 4 (or 6 upgraded). + + private static Task Test_Abrasive() + => TestHelpers.SingleCardTest( + name: "Abrasive applies Dex 1 + Thorns 4", + assert: (h, before) => + { + var dexErr = TestHelpers.ExpectPower(h.Player.Creature, "DEXTERITY", expectedAmount: 1); + if (dexErr != null) return dexErr; + return TestHelpers.ExpectPower(h.Player.Creature, "THORNS", expectedAmount: 4); + }); + + private static Task Test_AbrasivePlus() + => TestHelpers.SingleCardTest( + name: "Abrasive+ applies Dex 1 + Thorns 6", + upgradeLevel: 1, + assert: (h, before) => + { + var dexErr = TestHelpers.ExpectPower(h.Player.Creature, "DEXTERITY", expectedAmount: 1); + if (dexErr != null) return dexErr; + return TestHelpers.ExpectPower(h.Player.Creature, "THORNS", expectedAmount: 6); + }); + + // ─── Accelerant ────────────────────────────────────────────────────────── + + private static Task Test_Accelerant() + => TestHelpers.SingleCardTest( + name: "Accelerant applies power (1)", + assert: (h, before) => + TestHelpers.ExpectPower(h.Player.Creature, "ACCELERANT", expectedAmount: 1)); + + private static Task Test_AccelerantPlus() + => TestHelpers.SingleCardTest( + name: "Accelerant+ applies power (2)", + upgradeLevel: 1, + assert: (h, before) => + TestHelpers.ExpectPower(h.Player.Creature, "ACCELERANT", expectedAmount: 2)); + + // ─── Mirage ────────────────────────────────────────────────────────────── + // Block scales with sum of Poison stacks on enemies. Multiplier = sum. + // (CalcBase=0 + CalcExtra=1) * sum_poison. With 5 Poison: block = 5. + + private static Task Test_Mirage() + => TestHelpers.PowerThenPlayTest( + name: "Mirage gains block from enemy Poison (5 → 5 block)", + powerAmount: 5m, + powerOnEnemy: true, + assert: (h, before) => + TestHelpers.Expect(h.Player.Creature.Block - before.playerBlock, 5, "block")); + + private static Task Test_MiragePlus() + => TestHelpers.PowerThenPlayTest( + name: "Mirage+ gains block from enemy Poison (cost reduced)", + upgradeLevel: 1, + powerAmount: 5m, + powerOnEnemy: true, + assert: (h, before) => + TestHelpers.Expect(h.Player.Creature.Block - before.playerBlock, 5, "block")); + + // ─── Haze ──────────────────────────────────────────────────────────────── + // Apply Poison 4 (or 6 upgraded) to all enemies. + + private static Task Test_Haze() + => TestHelpers.SingleCardTest( + name: "Haze applies Poison 4 to enemy", + assert: (h, before) => + TestHelpers.ExpectPower(h.Dummy, "POISON", expectedAmount: 4)); + + private static Task Test_HazePlus() + => TestHelpers.SingleCardTest( + name: "Haze+ applies Poison 6 to enemy", + upgradeLevel: 1, + assert: (h, before) => + TestHelpers.ExpectPower(h.Dummy, "POISON", expectedAmount: 6)); + + // ─── Speedster ─────────────────────────────────────────────────────────── + // Power: SpeedsterPower 2. Upgrade adds Innate. + + private static Task Test_Speedster() + => TestHelpers.SingleCardTest( + name: "Speedster applies power (2)", + assert: (h, before) => + TestHelpers.ExpectPower(h.Player.Creature, "SPEEDSTER", expectedAmount: 2)); + + private static Task Test_SpeedsterPlus() + => TestHelpers.SingleCardTest( + name: "Speedster+ applies power (2, Innate)", + upgradeLevel: 1, + assert: (h, before) => + TestHelpers.ExpectPower(h.Player.Creature, "SPEEDSTER", expectedAmount: 2)); + + // ─── Shadowmeld ────────────────────────────────────────────────────────── + + private static Task Test_Shadowmeld() + => TestHelpers.SingleCardTest( + name: "Shadowmeld applies power (1)", + assert: (h, before) => + TestHelpers.ExpectPower(h.Player.Creature, "SHADOWMELD", expectedAmount: 1)); + + private static Task Test_ShadowmeldPlus() + => TestHelpers.SingleCardTest( + name: "Shadowmeld+ applies power (1, cost reduced)", + upgradeLevel: 1, + assert: (h, before) => + TestHelpers.ExpectPower(h.Player.Creature, "SHADOWMELD", expectedAmount: 1)); + + // ─── TheHunt ───────────────────────────────────────────────────────────── + // 10 dmg / 15 dmg. Entire OnPlay body is gated on `currentRoom is CombatRoom`. + // Our harness uses NullRunState (CurrentRoom=null), so the card no-ops. + // Skipping rather than asserting 0 damage — that would silently pass even + // if the harness later starts feeding a real CombatRoom. + + private static Task Test_TheHunt() + => TestHelpers.Skip("TheHunt deals 10 (gated on CombatRoom)", + "OnPlay is wrapped in `if (currentRoom is CombatRoom)`. NullRunState " + + "returns CurrentRoom=null, so the entire attack block is skipped. Needs " + + "a real CombatRoom on the run state to test."); + + private static Task Test_TheHuntPlus() + => TestHelpers.Skip("TheHunt+ deals 15 (gated on CombatRoom)", + "Same NullRunState gate as base TheHunt — entire OnPlay body is skipped " + + "when CurrentRoom is null."); +} diff --git a/src/SilentTests/Bucket6_PowersPoison.cs b/src/SilentTests/Bucket6_PowersPoison.cs new file mode 100644 index 0000000..37029d4 --- /dev/null +++ b/src/SilentTests/Bucket6_PowersPoison.cs @@ -0,0 +1,343 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.Models.Cards; + +namespace StS2Sim.SilentTests; + +/// +/// Bucket 6 — Powers & Poison Suite. Permanent combat powers (Envenom, +/// Footwork, etc.) and the poison-stack family. Tests need to apply the +/// power, then play another card to verify the effect (or watch poison tick). +/// +/// Scope (14 cards × 2 upgrade levels = 28 tests): +/// Envenom, NoxiousFumes, Footwork, BulletTime, Burst, WraithForm, +/// SerpentForm, InfiniteBlades, Afterimage, DeadlyPoison, BouncingFlask, +/// BubbleBubble, Untouchable, CorrosiveWave (also in B3, but the poison +/// side belongs here) +/// +internal static class Bucket6_PowersPoison +{ + public static async Task> RunAll() + { + var results = new List(); + + results.Add(await Test_Envenom()); + results.Add(await Test_EnvenomPlus()); + results.Add(await Test_NoxiousFumes()); + results.Add(await Test_NoxiousFumesPlus()); + results.Add(await Test_Footwork()); + results.Add(await Test_FootworkPlus()); + results.Add(await Test_BulletTime()); + results.Add(await Test_BulletTimePlus()); + results.Add(await Test_Burst()); + results.Add(await Test_BurstPlus()); + results.Add(await Test_WraithForm()); + results.Add(await Test_WraithFormPlus()); + results.Add(await Test_SerpentForm()); + results.Add(await Test_SerpentFormPlus()); + results.Add(await Test_InfiniteBlades()); + results.Add(await Test_InfiniteBladesPlus()); + results.Add(await Test_Afterimage()); + results.Add(await Test_AfterimagePlus()); + results.Add(await Test_DeadlyPoison()); + results.Add(await Test_DeadlyPoisonPlus()); + results.Add(await Test_BouncingFlask()); + results.Add(await Test_BouncingFlaskPlus()); + results.Add(await Test_BubbleBubble()); + results.Add(await Test_BubbleBubblePlus()); + results.Add(await Test_Untouchable()); + results.Add(await Test_UntouchablePlus()); + results.Add(await Test_CorrosiveWave()); + results.Add(await Test_CorrosiveWavePlus()); + + return results; + } + + // ─── Envenom ─── Power: when you deal unblocked attack damage, apply 1 + // (or 2 upgraded) poison. Test by playing Envenom, then a Strike. + + private static Task Test_Envenom() + => TestHelpers.SequenceTest( + name: "Envenom: poison applied after Strike (1)", + sequence: new[] + { + new Harness.DeckEntry(typeof(Envenom)), + new Harness.DeckEntry(typeof(StrikeSilent)), + }, + assert: (h, before) => + { + var powerErr = TestHelpers.ExpectPower(h.Player.Creature, "ENVENOM", expectedAmount: 1); + if (powerErr != null) return powerErr; + return TestHelpers.ExpectPower(h.Dummy, "POISON", expectedAmount: 1); + }); + + private static Task Test_EnvenomPlus() + => TestHelpers.SequenceTest( + name: "Envenom+: poison applied after Strike (2)", + sequence: new[] + { + new Harness.DeckEntry(typeof(Envenom), 1), + new Harness.DeckEntry(typeof(StrikeSilent)), + }, + assert: (h, before) => + { + var powerErr = TestHelpers.ExpectPower(h.Player.Creature, "ENVENOM", expectedAmount: 2); + if (powerErr != null) return powerErr; + return TestHelpers.ExpectPower(h.Dummy, "POISON", expectedAmount: 2); + }); + + // ─── NoxiousFumes ─── Power: at start of player turn, apply 2 (3 upgraded) + // poison to all enemies. Trigger via FireAfterSideTurnStart(Player). + + private static Task Test_NoxiousFumes() + => TestHelpers.SingleCardTest( + name: "NoxiousFumes: 2 poison on enemy after side-turn-start fires", + assert: (h, before) => + { + var powerErr = TestHelpers.ExpectPower(h.Player.Creature, "NOXIOUS", expectedAmount: 2); + if (powerErr != null) return powerErr; + TurnHooks.FireAfterSideTurnStart(h, CombatSide.Player).GetAwaiter().GetResult(); + return TestHelpers.ExpectPower(h.Dummy, "POISON", expectedAmount: 2); + }); + + private static Task Test_NoxiousFumesPlus() + => TestHelpers.SingleCardTest( + name: "NoxiousFumes+: 3 poison on enemy after side-turn-start fires", + upgradeLevel: 1, + assert: (h, before) => + { + var powerErr = TestHelpers.ExpectPower(h.Player.Creature, "NOXIOUS", expectedAmount: 3); + if (powerErr != null) return powerErr; + TurnHooks.FireAfterSideTurnStart(h, CombatSide.Player).GetAwaiter().GetResult(); + return TestHelpers.ExpectPower(h.Dummy, "POISON", expectedAmount: 3); + }); + + // ─── Footwork ─── Apply +2 (+3 upgraded) Dexterity to self. + + private static Task Test_Footwork() + => TestHelpers.SingleCardTest( + name: "Footwork: +2 Dexterity", + assert: (h, before) => TestHelpers.ExpectPower(h.Player.Creature, "DEXTERITY", expectedAmount: 2)); + + private static Task Test_FootworkPlus() + => TestHelpers.SingleCardTest( + name: "Footwork+: +3 Dexterity", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.ExpectPower(h.Player.Creature, "DEXTERITY", expectedAmount: 3)); + + // ─── BulletTime ─── Apply NoDrawPower 1, also sets every other hand card + // to free this turn. Upgrade reduces energy cost by 1 (3 → 2). + + private static Task Test_BulletTime() + => TestHelpers.SingleCardTest( + name: "BulletTime: NoDraw applied", + assert: (h, before) => TestHelpers.ExpectPower(h.Player.Creature, "NODRAW", expectedAmount: 1)); + + private static Task Test_BulletTimePlus() + => TestHelpers.SingleCardTest( + name: "BulletTime+: NoDraw applied (cost 2)", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.ExpectPower(h.Player.Creature, "NODRAW", expectedAmount: 1)); + + // ─── Burst ─── Apply BurstPower N: next N SKILLS (not Powers!) are played twice. + // Footwork is a Power, so it doesn't trigger Burst. Use Defend (Skill, +5 block): + // Burst then Defend → Defend plays twice → 10 block. + // Burst+ has 2 charges, so Defend+Defend → 20 block (each Defend doubles). + + private static Task Test_Burst() + => TestHelpers.SequenceTest( + name: "Burst: next skill plays twice (Defend → 10 block)", + sequence: new[] + { + new Harness.DeckEntry(typeof(Burst)), + new Harness.DeckEntry(typeof(DefendSilent)), + }, + assert: (h, before) => TestHelpers.Expect( + h.Player.Creature.Block - before.playerBlock, 10, "block (5 × 2 plays)")); + + private static Task Test_BurstPlus() + => TestHelpers.SequenceTest( + name: "Burst+: next 2 skills each play twice (Defend ×2 → 20 block)", + sequence: new[] + { + new Harness.DeckEntry(typeof(Burst), 1), + new Harness.DeckEntry(typeof(DefendSilent)), + new Harness.DeckEntry(typeof(DefendSilent)), + }, + assert: (h, before) => TestHelpers.Expect( + h.Player.Creature.Block - before.playerBlock, 20, "block (2 Defends × 2 plays each)")); + + // ─── WraithForm ─── Power: gain 2 (3 upgraded) Intangible + WraithFormPower 1. + + private static Task Test_WraithForm() + => TestHelpers.SingleCardTest( + name: "WraithForm: +2 Intangible + WraithForm power", + assert: (h, before) => + { + var intang = TestHelpers.ExpectPower(h.Player.Creature, "INTANGIBLE", expectedAmount: 2); + if (intang != null) return intang; + return TestHelpers.ExpectPower(h.Player.Creature, "WRAITHFORM", expectedAmount: 1); + }); + + private static Task Test_WraithFormPlus() + => TestHelpers.SingleCardTest( + name: "WraithForm+: +3 Intangible + WraithForm power", + upgradeLevel: 1, + assert: (h, before) => + { + var intang = TestHelpers.ExpectPower(h.Player.Creature, "INTANGIBLE", expectedAmount: 3); + if (intang != null) return intang; + return TestHelpers.ExpectPower(h.Player.Creature, "WRAITHFORM", expectedAmount: 1); + }); + + // ─── SerpentForm ─── Power: SerpentFormPower 4 (6 upgraded). Each card + // played hits a random enemy for N. Just verify the power lands. + + private static Task Test_SerpentForm() + => TestHelpers.SingleCardTest( + name: "SerpentForm: SerpentForm power 4", + assert: (h, before) => TestHelpers.ExpectPower(h.Player.Creature, "SERPENT", expectedAmount: 4)); + + private static Task Test_SerpentFormPlus() + => TestHelpers.SingleCardTest( + name: "SerpentForm+: SerpentForm power 6", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.ExpectPower(h.Player.Creature, "SERPENT", expectedAmount: 6)); + + // ─── InfiniteBlades ─── Power: InfiniteBladesPower 1. At start of each + // draw, generate a Shiv. Verify the power applies (Shiv generation + // requires a draw cycle to fire). + + private static Task Test_InfiniteBlades() + => TestHelpers.SingleCardTest( + name: "InfiniteBlades: power applied", + assert: (h, before) => TestHelpers.ExpectPower(h.Player.Creature, "INFINITEBLADES", expectedAmount: 1)); + + private static Task Test_InfiniteBladesPlus() + => TestHelpers.SingleCardTest( + name: "InfiniteBlades+: power applied (Innate added)", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.ExpectPower(h.Player.Creature, "INFINITEBLADES", expectedAmount: 1)); + + // ─── Afterimage ─── Power: gain 1 block whenever a card is played. + // Test by playing Afterimage then a Defend; we expect the Defend to + // give its normal block PLUS 1 extra from Afterimage triggering on + // AfterimagePower hooks BeforeCardPlayed (records amount) and AfterCardPlayed + // (grants block). BeforeCardPlayed fires BEFORE OnPlay runs, so when Afterimage + // itself is played, its OWN BeforeCardPlayed fires before AfterimagePower exists + // → no record for itself → no block from its own play. Subsequent cards do + // trigger it. Two follow-up Strikes → 2 block. + + private static Task Test_Afterimage() + => TestHelpers.SequenceTest( + name: "Afterimage: +1 block per card played AFTER it (2 Strikes → 2 block)", + sequence: new[] + { + new Harness.DeckEntry(typeof(Afterimage)), + new Harness.DeckEntry(typeof(StrikeSilent)), + new Harness.DeckEntry(typeof(StrikeSilent)), + }, + assert: (h, before) => + { + var pwr = TestHelpers.ExpectPower(h.Player.Creature, "AFTERIMAGE", expectedAmount: 1); + if (pwr != null) return pwr; + return TestHelpers.Expect(h.Player.Creature.Block, 2, "block (1 per Strike)"); + }); + + private static Task Test_AfterimagePlus() + => TestHelpers.SequenceTest( + name: "Afterimage+: same effect (2 Strikes → 2 block, plus Innate keyword)", + sequence: new[] + { + new Harness.DeckEntry(typeof(Afterimage), 1), + new Harness.DeckEntry(typeof(StrikeSilent)), + new Harness.DeckEntry(typeof(StrikeSilent)), + }, + assert: (h, before) => + { + var pwr = TestHelpers.ExpectPower(h.Player.Creature, "AFTERIMAGE", expectedAmount: 1); + if (pwr != null) return pwr; + return TestHelpers.Expect(h.Player.Creature.Block, 2, "block"); + }); + + // ─── DeadlyPoison ─── Apply 5 (7 upgraded) poison to one enemy. + + private static Task Test_DeadlyPoison() + => TestHelpers.SingleCardTest( + name: "DeadlyPoison: 5 poison on enemy", + assert: (h, before) => TestHelpers.ExpectPower(h.Dummy, "POISON", expectedAmount: 5)); + + private static Task Test_DeadlyPoisonPlus() + => TestHelpers.SingleCardTest( + name: "DeadlyPoison+: 7 poison on enemy", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.ExpectPower(h.Dummy, "POISON", expectedAmount: 7)); + + // ─── BouncingFlask ─── Apply 3 poison N times to random enemies. + // Repeat = 3 base, 4 upgraded. Single dummy = stacked. + + private static Task Test_BouncingFlask() + => TestHelpers.SingleCardTest( + name: "BouncingFlask: 3 poison ×3 on dummy = 9", + assert: (h, before) => TestHelpers.ExpectPower(h.Dummy, "POISON", expectedAmount: 9)); + + private static Task Test_BouncingFlaskPlus() + => TestHelpers.SingleCardTest( + name: "BouncingFlask+: 3 poison ×4 on dummy = 12", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.ExpectPower(h.Dummy, "POISON", expectedAmount: 12)); + + // ─── BubbleBubble ─── If target has Poison, apply 9 (12 upgraded) more. + // Otherwise, no-op. Sequence: apply DeadlyPoison first to seed poison, + // then play BubbleBubble — total should be 5 + 9 = 14 (or 7 + 12 = 19). + + private static Task Test_BubbleBubble() + => TestHelpers.SequenceTest( + name: "BubbleBubble (after DeadlyPoison): 5 + 9 = 14 poison", + sequence: new[] + { + new Harness.DeckEntry(typeof(DeadlyPoison)), + new Harness.DeckEntry(typeof(BubbleBubble)), + }, + assert: (h, before) => TestHelpers.ExpectPower(h.Dummy, "POISON", expectedAmount: 14)); + + private static Task Test_BubbleBubblePlus() + => TestHelpers.SequenceTest( + name: "BubbleBubble+ (after DeadlyPoison): 5 + 12 = 17 poison", + sequence: new[] + { + new Harness.DeckEntry(typeof(DeadlyPoison)), + new Harness.DeckEntry(typeof(BubbleBubble), 1), + }, + assert: (h, before) => TestHelpers.ExpectPower(h.Dummy, "POISON", expectedAmount: 17)); + + // ─── Untouchable ─── Skill, gain 6 (8 upgraded) block. Sly keyword. + + private static Task Test_Untouchable() + => TestHelpers.SingleCardTest( + name: "Untouchable: 6 block", + assert: (h, before) => TestHelpers.Expect(h.Player.Creature.Block - before.playerBlock, 6, "block")); + + private static Task Test_UntouchablePlus() + => TestHelpers.SingleCardTest( + name: "Untouchable+: 8 block", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.Expect(h.Player.Creature.Block - before.playerBlock, 8, "block")); + + // ─── CorrosiveWave ─── Skill that applies CorrosiveWavePower 2 (3 upgraded) + // to self. Just verify the power lands. + + private static Task Test_CorrosiveWave() + => TestHelpers.SingleCardTest( + name: "CorrosiveWave: power 2 on self", + assert: (h, before) => TestHelpers.ExpectPower(h.Player.Creature, "CORROSIVE", expectedAmount: 2)); + + private static Task Test_CorrosiveWavePlus() + => TestHelpers.SingleCardTest( + name: "CorrosiveWave+: power 3 on self", + upgradeLevel: 1, + assert: (h, before) => TestHelpers.ExpectPower(h.Player.Creature, "CORROSIVE", expectedAmount: 3)); +} diff --git a/src/SilentTests/SilentTestsRunner.cs b/src/SilentTests/SilentTestsRunner.cs new file mode 100644 index 0000000..76d83c9 --- /dev/null +++ b/src/SilentTests/SilentTestsRunner.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace StS2Sim.SilentTests; + +/// +/// Top-level runner for every Silent card test bucket. Each bucket file +/// (Bucket1_BasicAttacks.cs, etc.) exposes a public static +/// Task<IReadOnlyList<TestResult>> RunAll() method. +/// +/// Invoke from CLI: dotnet run -c Release -- silent-tests +/// +internal static class SilentTestsRunner +{ + public static async Task RunAll() + { + Harness.Bootstrap(); + Console.WriteLine("=== StS2 Silent Card Test Battery ===\n"); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + + // Each bucket gets called sequentially so we don't fight over the + // singleton CombatManager. The combats inside each bucket are also + // sequential for the same reason. + var buckets = new (string Name, Func>> Run)[] + { + ("Bucket 1 - Basic Attacks", Bucket1_BasicAttacks.RunAll), + ("Bucket 2 - Multi-hit & Shiv Attacks", Bucket2_MultiHitShiv.RunAll), + ("Bucket 3 - Conditional/Burst Attacks", Bucket3_ConditionalBurst.RunAll), + ("Bucket 4 - Defensive & Draw Skills", Bucket4_DefensiveDraw.RunAll), + ("Bucket 5 - Utility & Card Manipulation", Bucket5_UtilityCardManip.RunAll), + ("Bucket 6 - Powers & Poison Suite", Bucket6_PowersPoison.RunAll), + }; + + var allResults = new List<(string Bucket, TestHelpers.TestResult Result)>(); + foreach (var (name, run) in buckets) + { + Console.WriteLine($"\n--- {name} ---"); + IReadOnlyList results; + try + { + results = await run(); + } + catch (Exception ex) + { + Console.WriteLine($" BUCKET CRASHED: {ex.GetType().Name}: {ex.Message}"); + continue; + } + + foreach (var r in results) + { + allResults.Add((name, r)); + PrintResult(r); + } + } + + sw.Stop(); + PrintSummary(allResults, sw.Elapsed); + + // Return non-zero exit code if any tests crashed (real bugs). Failures + // are expected during development; crashes are not. + var crashCount = allResults.Count(x => x.Result.Outcome == TestHelpers.Outcome.Crash); + return crashCount > 0 ? 2 : 0; + } + + private static void PrintResult(TestHelpers.TestResult r) + { + var tag = r.Outcome switch + { + TestHelpers.Outcome.Pass => "PASS", + TestHelpers.Outcome.Fail => "FAIL", + TestHelpers.Outcome.Crash => "CRASH", + TestHelpers.Outcome.Skipped => "SKIP", + _ => "?", + }; + var detail = r.Detail is null ? "" : " — " + r.Detail; + Console.WriteLine($" [{tag,-5}] {r.Name}{detail}"); + } + + private static void PrintSummary(List<(string Bucket, TestHelpers.TestResult Result)> all, TimeSpan elapsed) + { + Console.WriteLine(); + Console.WriteLine("=== Summary ==="); + + var byBucket = all.GroupBy(x => x.Bucket); + foreach (var g in byBucket) + { + var pass = g.Count(x => x.Result.Outcome == TestHelpers.Outcome.Pass); + var fail = g.Count(x => x.Result.Outcome == TestHelpers.Outcome.Fail); + var crash = g.Count(x => x.Result.Outcome == TestHelpers.Outcome.Crash); + var skip = g.Count(x => x.Result.Outcome == TestHelpers.Outcome.Skipped); + Console.WriteLine($" {g.Key,-46} pass={pass,3} fail={fail,3} crash={crash,3} skip={skip,3}"); + } + + var totalPass = all.Count(x => x.Result.Outcome == TestHelpers.Outcome.Pass); + var totalFail = all.Count(x => x.Result.Outcome == TestHelpers.Outcome.Fail); + var totalCrash = all.Count(x => x.Result.Outcome == TestHelpers.Outcome.Crash); + var totalSkip = all.Count(x => x.Result.Outcome == TestHelpers.Outcome.Skipped); + Console.WriteLine(); + Console.WriteLine($" TOTAL: {totalPass}/{all.Count} pass · {totalFail} fail · {totalCrash} crash · {totalSkip} skip"); + Console.WriteLine($" Elapsed: {elapsed.TotalSeconds:F1}s"); + + if (totalCrash > 0) + { + Console.WriteLine(); + Console.WriteLine("=== CRASHES (real bugs — fix the harness) ==="); + foreach (var (bucket, r) in all.Where(x => x.Result.Outcome == TestHelpers.Outcome.Crash)) + Console.WriteLine($" [{bucket}] {r.Name} — {r.Detail}"); + } + } +} diff --git a/src/SilentTests/TestHelpers.cs b/src/SilentTests/TestHelpers.cs new file mode 100644 index 0000000..4d64b7b --- /dev/null +++ b/src/SilentTests/TestHelpers.cs @@ -0,0 +1,315 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MegaCrit.Sts2.Core.Entities.Cards; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.Models.Characters; +using MegaCrit.Sts2.Core.Models.Powers; + +namespace StS2Sim.SilentTests; + +/// +/// Shared scaffolding for the per-card test batteries. Each agent's bucket file +/// uses these patterns so we get consistent setup and consistent failure +/// reporting across hundreds of tests. +/// +/// All tests run against an isolated combat with a Silent player and a single +/// BigDummy enemy. Energy is set to 3 by default; agents can override. +/// +internal static class TestHelpers +{ + /// + /// One test result. Outcome buckets: + /// - Pass: assertion held, expected value matched. + /// - Fail: assertion didn't hold (test ran cleanly, just got the wrong answer). + /// - Crash: harness threw an exception. ALWAYS a real bug — either missing + /// shim, missing relic effect, missing hook, etc. Triage these first. + /// - Skipped: agent decided this card uses a mechanic we know is unimplemented + /// (multi-target, random target from pool, choose-card-from-discard). + /// Includes a reason so we can prioritize what to add next. + /// + public enum Outcome { Pass, Fail, Crash, Skipped } + + public sealed record TestResult(string Name, Outcome Outcome, string? Detail = null); + + /// Snapshot of state before a card is played. Tests assert against deltas. + public record SnapshotBefore(int dummyHp, int playerHp, int playerBlock, int energy, int handSize, int drawSize, int discardSize, int exhaustSize); + + public static SnapshotBefore Snapshot(Harness.CombatHarness h) + { + var pcs = h.Player.PlayerCombatState!; + return new SnapshotBefore( + dummyHp: h.Dummy.CurrentHp, + playerHp: h.Player.Creature.CurrentHp, + playerBlock: h.Player.Creature.Block, + energy: pcs.Energy, + handSize: pcs.Hand.Cards.Count, + drawSize: pcs.DrawPile.Cards.Count, + discardSize: pcs.DiscardPile.Cards.Count, + exhaustSize: pcs.ExhaustPile.Cards.Count); + } + + // ─── Pattern 1: SingleCardTest ────────────────────────────────────────── + // + // The 80% case. Spin up combat with the card under test (plus optional + // extras for draw-pile fodder), put it in hand, play it against the dummy, + // run the assertion. Use this whenever the test reads as + // "play X, expect Y damage / block / power". + + /// + /// Test a single Silent card. The card gets put in hand and played at the dummy + /// (or self for TargetType.Self cards). Assertion receives (harness, before-snapshot) + /// and returns null on success or a failure reason string. + /// + public static async Task SingleCardTest( + string name, + Func assert, + IReadOnlyList? extraDeckCards = null, + int upgradeLevel = 0, + int energy = 3) + where TCard : CardModel + { + var deck = new List { new(typeof(TCard), upgradeLevel) }; + if (extraDeckCards != null) + deck.AddRange(extraDeckCards.Select(t => new Harness.DeckEntry(t))); + + return await RunInCombat(name, deck, async (h, _) => + { + var pcs = h.Player.PlayerCombatState!; + Reflect.SetEnergy(pcs, energy); + + // Move the card under test into hand. + var card = pcs.DrawPile.Cards.OfType().First(); + pcs.DrawPile.RemoveInternal(card); + pcs.Hand.AddInternal(card); + + var before = Snapshot(h); + await PlayCard(h, card); + return assert(h, before); + }); + } + + // ─── Pattern 2: PowerThenPlayTest ─────────────────────────────────────── + // + // For testing cards whose value comes from a power being active. Apply + // the power directly (PowerCmd.Apply), then play the card under test. + // Agents using this don't need to know the power's source card. + + /// + /// Apply a Power to the player at a specific amount, then play a card. + /// Use for "Strength makes Strike do +N", "Vulnerable on enemy makes Strike do 1.5x", etc. + /// + public static async Task PowerThenPlayTest( + string name, + decimal powerAmount, + bool powerOnEnemy, + Func assert, + IReadOnlyList? extraDeckCards = null, + int upgradeLevel = 0, + int energy = 3) + where TPower : PowerModel + where TCard : CardModel + { + var deck = new List { new(typeof(TCard), upgradeLevel) }; + if (extraDeckCards != null) + deck.AddRange(extraDeckCards.Select(t => new Harness.DeckEntry(t))); + + return await RunInCombat(name, deck, async (h, _) => + { + var pcs = h.Player.PlayerCombatState!; + Reflect.SetEnergy(pcs, energy); + + var target = powerOnEnemy ? h.Dummy : h.Player.Creature; + await MegaCrit.Sts2.Core.Commands.PowerCmd.Apply( + target, powerAmount, h.Player.Creature, null); + + var card = pcs.DrawPile.Cards.OfType().First(); + pcs.DrawPile.RemoveInternal(card); + pcs.Hand.AddInternal(card); + + var before = Snapshot(h); + await PlayCard(h, card); + return assert(h, before); + }); + } + + // ─── Pattern 3: SequenceTest ──────────────────────────────────────────── + // + // Play multiple cards in order, assert against the final state. Use for + // chains like "Bash then Strike", "Inflame then Strike", "Backstab then Backstab". + + /// + /// Play a sequence of cards in order against the dummy (or self where appropriate). + /// Assertion runs against the final harness state and the initial snapshot. + /// + public static async Task SequenceTest( + string name, + IReadOnlyList sequence, + Func assert, + IReadOnlyList? extraDeckCards = null, + int energy = 99) + { + var deck = new List(sequence); + if (extraDeckCards != null) + deck.AddRange(extraDeckCards.Select(t => new Harness.DeckEntry(t))); + + return await RunInCombat(name, deck, async (h, _) => + { + var pcs = h.Player.PlayerCombatState!; + Reflect.SetEnergy(pcs, energy); + + // Move every sequence card from draw pile to hand, in order. + var cardsInHand = new List(); + foreach (var entry in sequence) + { + var c = pcs.DrawPile.Cards.FirstOrDefault(x => x.GetType() == entry.CardType + && (entry.UpgradeLevel == 0 || x.CurrentUpgradeLevel == entry.UpgradeLevel)); + if (c == null) return $"setup failed: could not find {entry.CardType.Name} (upgrade {entry.UpgradeLevel}) in draw pile"; + pcs.DrawPile.RemoveInternal(c); + pcs.Hand.AddInternal(c); + cardsInHand.Add(c); + } + + var before = Snapshot(h); + foreach (var c in cardsInHand) + await PlayCard(h, c); + return assert(h, before); + }); + } + + // ─── Pattern 4: PreloadHandTest ───────────────────────────────────────── + // + // For cards whose effect depends on what's in your hand (HandTrick, Reflex, + // CalculatedGamble) or in piles (Tactician requires being discarded). + // Caller fully controls hand layout, then plays one card under test. + + /// + /// Put a specific set of cards in hand (in addition to the card under test), + /// then play the card under test against the dummy. Agents define expected + /// behavior based on the constructed hand. + /// + public static async Task PreloadHandTest( + string name, + IReadOnlyList handLoadout, + Func assert, + int upgradeLevel = 0, + int energy = 3) + where TCard : CardModel + { + var deck = new List { new(typeof(TCard), upgradeLevel) }; + deck.AddRange(handLoadout.Select(t => new Harness.DeckEntry(t))); + + return await RunInCombat(name, deck, async (h, _) => + { + var pcs = h.Player.PlayerCombatState!; + Reflect.SetEnergy(pcs, energy); + + // Move every card from draw pile to hand. Card under test goes last so it's + // the most recently added, but order doesn't matter for play. + foreach (var c in pcs.DrawPile.Cards.ToList()) + { + pcs.DrawPile.RemoveInternal(c); + pcs.Hand.AddInternal(c); + } + + var card = pcs.Hand.Cards.OfType().First(); + + var before = Snapshot(h); + await PlayCard(h, card); + return assert(h, before); + }); + } + + // ─── Skip helper ───────────────────────────────────────────────────────── + + /// + /// Mark a test as skipped because the mechanic isn't implementable in the + /// current harness. Reason should explain WHY (e.g. "needs random enemy + /// target", "depends on monster intent", "requires CardSelectCmd from discard"). + /// + public static Task Skip(string name, string reason) + => Task.FromResult(new TestResult(name, Outcome.Skipped, reason)); + + // ─── Internals ─────────────────────────────────────────────────────────── + + private static async Task RunInCombat( + string name, + List deck, + Func> body) + { + Harness.CombatHarness? h = null; + try + { + h = Harness.BeginCombat(deck, shuffleSeed: 1); + var failure = await body(h, null); + return failure is null + ? new TestResult(name, Outcome.Pass) + : new TestResult(name, Outcome.Fail, failure); + } + catch (Exception ex) + { + return new TestResult(name, Outcome.Crash, + $"{ex.GetType().Name}: {ex.Message}"); + } + finally + { + if (h != null) Harness.EndCombat(); + } + } + + /// + /// Play a card with the right target (Self for TargetType.Self, dummy otherwise) + /// and the right ResourceInfo. Most agents shouldn't need to call this directly — + /// the SingleCardTest / SequenceTest helpers do it for you. + /// + public static async Task PlayCard(Harness.CombatHarness h, CardModel card) + { + var pcs = h.Player.PlayerCombatState!; + + // X-cost cards (Skewer, Outbreak, Malaise, Eradicate, Cascade, etc.) + // need EnergyCost.CapturedXValue set to the energy actually spent before + // OnPlayWrapper runs. The game's CardCmd.Play does this; we bypass that + // path, so we replicate the capture here. + if (card.EnergyCost.CostsX) + card.EnergyCost.CapturedXValue = pcs.Energy; + + var cost = card.EnergyCost.GetResolved(); + var resources = new ResourceInfo + { + EnergySpent = cost, + EnergyValue = cost, + StarsSpent = 0, + StarValue = 0, + }; + var target = card.TargetType == MegaCrit.Sts2.Core.Entities.Cards.TargetType.Self + ? h.Player.Creature + : h.Dummy; + + pcs.LoseEnergy(cost); + await card.OnPlayWrapper(h.Ctx, target, isAutoPlay: true, resources, skipCardPileVisuals: true); + } + + /// Compose a "expected vs got" failure string. Returns null on match. + public static string? Expect(T actual, T expected, string what) where T : IEquatable + => actual.Equals(expected) ? null : $"expected {what}={expected}, got {actual}"; + + /// + /// Verify a power exists on a creature (case-insensitive, underscore-insensitive + /// substring match). Game power IDs are NO_DRAW_POWER, WRAITH_FORM_POWER, + /// INFINITE_BLADES_POWER etc.; tests can search for "NODRAW", "WRAITHFORM", + /// "INFINITEBLADES", "WRAITH_FORM", or any variant — they all match. + /// + public static string? ExpectPower(MegaCrit.Sts2.Core.Entities.Creatures.Creature creature, string powerIdContains, int? expectedAmount = null) + { + static string norm(string s) => s.Replace("_", ""); + var needle = norm(powerIdContains); + var found = creature.Powers.FirstOrDefault(p => + norm(p.Id.Entry).Contains(needle, StringComparison.OrdinalIgnoreCase)); + if (found == null) + return $"expected power containing '{powerIdContains}', got [{string.Join(",", creature.Powers.Select(p => p.Id.Entry))}]"; + if (expectedAmount.HasValue && found.Amount != expectedAmount.Value) + return $"expected {powerIdContains} amount={expectedAmount.Value}, got {found.Amount}"; + return null; + } +} -- 2.51.2