diff --git a/Core/Cache/MinimumAttackerWinCache.cs b/Core/Cache/MinimumAttackerWinCache.cs
new file mode 100644
index 0000000..f4d6edd
--- /dev/null
+++ b/Core/Cache/MinimumAttackerWinCache.cs
@@ -0,0 +1,141 @@
+using System.Collections.Concurrent;
+
+namespace Risk.Dice;
+
+public static class MinimumAttackerWinCache
+{
+ ///
+ /// For a particular game, and
+ /// will not change, so this is effectively an (int) key. This should
+ /// result in very efficient lookups for the vast majority of rolls throughout the game,
+ /// as a single attack early against a particular stack of defenders (say, one) will
+ /// provide an O(1) solution for that defender stack size for the remainder of the game.
+ ///
+ private static readonly ConcurrentDictionary<(
+ int defenders,
+ RoundConfig round,
+ BalanceConfig balance), int> Cache
+ = [];
+
+ ///
+ /// Gets the minimum number of attackers required to destroy the defending troops with 100% certainty
+ /// if such a solution exists.
+ ///
+ /// Only supports balanced blitz; balanceConfig required.
+ public static int? Get(int defenders, RoundConfig roundConfig, BalanceConfig balanceConfig)
+ {
+ // True random not supported
+ if (balanceConfig == null)
+ {
+ throw new ArgumentNullException(nameof(balanceConfig),
+ "MinWinAttackerCache requires Balanced Blitz configuration.");
+ }
+
+ // RoundConfig must be augmented, we'll need the attacking/defending DiceAugments
+ if (roundConfig.AppliedAttackDiceAugment is null ||
+ roundConfig.AppliedDefenseDiceAugment is null)
+ {
+ throw new ArgumentException("RoundConfig must be augmented.");
+ }
+
+ // Avoid trivial case
+ if (defenders <= 0)
+ {
+ return 1;
+ }
+
+ return Cache.TryGetValue((defenders, roundConfig, balanceConfig), out var minimumAttackers)
+ ? minimumAttackers
+ : Compute(defenders, roundConfig, balanceConfig);
+ }
+
+ ///
+ /// Populate the cache for 1..maxDefenders under a particular balanced-blitz setting.
+ /// Builds a single WinChanceInfo table and scans each column once.
+ ///
+ private static int? Compute(int maxDefenders, RoundConfig roundConfig, BalanceConfig balanceConfig)
+ {
+ // Certain dice augments need more or fewer attacking dice to find a 100% win probability
+ var multiplier = GetMaxAttackerMultiplier(
+ roundConfig.AppliedAttackDiceAugment.Value,
+ roundConfig.AppliedDefenseDiceAugment.Value);
+ var maxAttackers = (int)Math.Ceiling(multiplier * maxDefenders) + 10;
+
+ // Calculate the solution using the existing WinChanceInfo logic
+ var winChanceInfo = WinChanceCache.Get(Math.Max(maxAttackers, maxDefenders) + 1, roundConfig, balanceConfig);
+ winChanceInfo.Calculate();
+
+ // Populate the cache until the winning percentage drops below one
+ for (var d = 1; d <= maxDefenders; d++)
+ {
+ // Short-circuit if no solutions exist or if we've already calculated the solution
+ if (winChanceInfo.WinChances[maxAttackers, d] < 1.0f - 1e-6f)
+ {
+ continue;
+ }
+
+ // Protects against refactoring that no longer uses the Cache.Get pattern
+ if (Cache.TryGetValue((d, roundConfig, balanceConfig), out var attackers))
+ {
+ if (d == maxDefenders)
+ {
+ return attackers;
+ }
+
+ continue;
+ }
+
+ // Work up from the fewest available attacking troops.
+ for (var a = 1; a <= maxAttackers; a++)
+ {
+ if (winChanceInfo.WinChances[a, d] >= 1.0f - 1e-6f)
+ {
+ _ = Cache.TryAdd((d, roundConfig, balanceConfig), a);
+ if (d == maxDefenders)
+ {
+ return a;
+ }
+
+ break;
+ }
+ }
+ }
+
+ // Fail-safe, current behavior
+ if (maxDefenders > 0
+ && winChanceInfo.WinChances[maxAttackers, maxDefenders] >= 1.0f)
+ {
+ return maxAttackers;
+ }
+
+ return null;
+ }
+
+ private static double GetMaxAttackerMultiplier(DiceAugment attackDiceAugment, DiceAugment defenseDiceAugment)
+ {
+ var multiplier = 1.0;
+
+ if (defenseDiceAugment.HasFlag(DiceAugment.OnCapital))
+ {
+ multiplier += 1.0;
+ }
+
+ if (defenseDiceAugment.HasFlag(DiceAugment.IsBehindWall))
+ {
+ multiplier += 1.0;
+ }
+
+ if (defenseDiceAugment.HasFlag(DiceAugment.IsZombie))
+ {
+ multiplier -= 0.5;
+ }
+
+ if (attackDiceAugment.HasFlag(DiceAugment.IsZombie))
+ {
+ multiplier += 0.5;
+ }
+
+ // Floor so we don't go below 1x
+ return Math.Max(multiplier, 1.0);
+ }
+}
\ No newline at end of file
diff --git a/Core/Config/RoundConfig.cs b/Core/Config/RoundConfig.cs
index 389cbae..e209b06 100644
--- a/Core/Config/RoundConfig.cs
+++ b/Core/Config/RoundConfig.cs
@@ -41,6 +41,8 @@ public struct RoundConfig : IEquatable
[SerializeField] private int _attackDiceCount;
[SerializeField] private int _defendDiceCount;
[SerializeField] private bool _favourDefenderOnDraw;
+ [SerializeField] private DiceAugment? _appliedAttackDiceAugment;
+ [SerializeField] private DiceAugment? _appliedDefenseDiceAugment;
public int DiceFaceCount => _diceFaceCount;
public int AttackDiceCount => _attackDiceCount;
@@ -51,6 +53,9 @@ public struct RoundConfig : IEquatable
public static RoundConfig Default => new RoundConfig(6, 3, 2, true);
+ public DiceAugment? AppliedAttackDiceAugment => _appliedAttackDiceAugment;
+ public DiceAugment? AppliedDefenseDiceAugment => _appliedDefenseDiceAugment;
+
public RoundConfig (int diceFaceCount, int attackDiceCount, int defendDiceCount, bool favourDefenderOnDraw)
{
#if UNITY_ASSERTIONS
@@ -75,6 +80,9 @@ public void SetMaxAttackDice (int maxAttackDice)
public void ApplyAugments (DiceAugment attackAugment, DiceAugment defendAugment)
{
+ _appliedAttackDiceAugment = attackAugment;
+ _appliedDefenseDiceAugment = defendAugment;
+
if ((attackAugment & DiceAugment.IsZombie) != 0)
{
_attackDiceCount = Math.Max(_attackDiceCount - 1, 1);