From e97480e525cb7552bb58783d08ae146273007483 Mon Sep 17 00:00:00 2001 From: Zac Petit Date: Mon, 16 Feb 2026 15:36:57 -0500 Subject: [PATCH 1/6] minimum winning attacking troops cache Implements a cache that iterates over WinCacheInfo solutions to determine what the minimum number of attacking troops is required to destroy the defending troops. Supports combinations of attacking/defending DiceAugment values. Consume when attacking to minimize attacker loses and avoid requiring use of 'perfect slider.' --- Core/Cache/MinimumAttackerWinCache.cs | 146 ++++++++++++++++++++++++++ Core/Config/RoundConfig.cs | 8 ++ 2 files changed, 154 insertions(+) create mode 100644 Core/Cache/MinimumAttackerWinCache.cs diff --git a/Core/Cache/MinimumAttackerWinCache.cs b/Core/Cache/MinimumAttackerWinCache.cs new file mode 100644 index 0000000..0ebed39 --- /dev/null +++ b/Core/Cache/MinimumAttackerWinCache.cs @@ -0,0 +1,146 @@ +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, DiceAugment) 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 zero + int? minimumAttackers = null; + 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. + // Guaranteed success will never occur with fewer than two troops (zombies). + // TODO: binary search + 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 (minimumAttackers == null && maxDefenders > 0 + && winChanceInfo.WinChances[maxAttackers, maxDefenders] >= 1.0f) + { + return maxAttackers; + } + + return minimumAttackers; + } + + 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); From 81dbd5170035ad0de698f2c23b25f0683529bffa Mon Sep 17 00:00:00 2001 From: Zac Petit Date: Mon, 16 Feb 2026 15:45:48 -0500 Subject: [PATCH 2/6] fix comment --- Core/Cache/MinimumAttackerWinCache.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Cache/MinimumAttackerWinCache.cs b/Core/Cache/MinimumAttackerWinCache.cs index 0ebed39..3139475 100644 --- a/Core/Cache/MinimumAttackerWinCache.cs +++ b/Core/Cache/MinimumAttackerWinCache.cs @@ -6,7 +6,7 @@ public static class MinimumAttackerWinCache { /// /// For a particular game, and - /// will not change, so this is effectively an (int, DiceAugment) key. This should + /// 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. From e32a138c42b5a301958ce20114f483f4f5cd00d5 Mon Sep 17 00:00:00 2001 From: Zac Petit Date: Mon, 16 Feb 2026 16:26:20 -0500 Subject: [PATCH 3/6] fix more comments --- Core/Cache/MinimumAttackerWinCache.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Core/Cache/MinimumAttackerWinCache.cs b/Core/Cache/MinimumAttackerWinCache.cs index 3139475..5baab86 100644 --- a/Core/Cache/MinimumAttackerWinCache.cs +++ b/Core/Cache/MinimumAttackerWinCache.cs @@ -66,7 +66,7 @@ public static class MinimumAttackerWinCache var winChanceInfo = WinChanceCache.Get(Math.Max(maxAttackers, maxDefenders) + 1, roundConfig, balanceConfig); winChanceInfo.Calculate(); - // Populate the cache until the winning percentage drops below zero + // Populate the cache until the winning percentage drops below one int? minimumAttackers = null; for (var d = 1; d <= maxDefenders; d++) { @@ -89,7 +89,6 @@ public static class MinimumAttackerWinCache // Work up from the fewest available attacking troops. // Guaranteed success will never occur with fewer than two troops (zombies). - // TODO: binary search for (var a = 1; a <= maxAttackers; a++) { if (winChanceInfo.WinChances[a, d] >= 1.0f - 1e-6f) From 47feba794ad4a02c06a98bf166cf832dc64d1eee Mon Sep 17 00:00:00 2001 From: Zac Petit Date: Mon, 16 Feb 2026 16:27:10 -0500 Subject: [PATCH 4/6] fix more comments --- Core/Cache/MinimumAttackerWinCache.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Core/Cache/MinimumAttackerWinCache.cs b/Core/Cache/MinimumAttackerWinCache.cs index 5baab86..f7b1cb7 100644 --- a/Core/Cache/MinimumAttackerWinCache.cs +++ b/Core/Cache/MinimumAttackerWinCache.cs @@ -88,7 +88,6 @@ public static class MinimumAttackerWinCache } // Work up from the fewest available attacking troops. - // Guaranteed success will never occur with fewer than two troops (zombies). for (var a = 1; a <= maxAttackers; a++) { if (winChanceInfo.WinChances[a, d] >= 1.0f - 1e-6f) From b04eca748b864d9ed8dd125b90800127c3c22eb4 Mon Sep 17 00:00:00 2001 From: Zac Petit Date: Mon, 16 Feb 2026 16:28:25 -0500 Subject: [PATCH 5/6] refactor to remove underused variable --- Core/Cache/MinimumAttackerWinCache.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Core/Cache/MinimumAttackerWinCache.cs b/Core/Cache/MinimumAttackerWinCache.cs index f7b1cb7..9d0cf1b 100644 --- a/Core/Cache/MinimumAttackerWinCache.cs +++ b/Core/Cache/MinimumAttackerWinCache.cs @@ -67,7 +67,6 @@ public static class MinimumAttackerWinCache winChanceInfo.Calculate(); // Populate the cache until the winning percentage drops below one - int? minimumAttackers = null; for (var d = 1; d <= maxDefenders; d++) { // Short-circuit if no solutions exist or if we've already calculated the solution @@ -104,13 +103,13 @@ public static class MinimumAttackerWinCache } // Fail-safe, current behavior - if (minimumAttackers == null && maxDefenders > 0 - && winChanceInfo.WinChances[maxAttackers, maxDefenders] >= 1.0f) + if (maxDefenders > 0 + && winChanceInfo.WinChances[maxAttackers, maxDefenders] >= 1.0f) { return maxAttackers; } - return minimumAttackers; + return null; } private static double GetMaxAttackerMultiplier(DiceAugment attackDiceAugment, DiceAugment defenseDiceAugment) From e309dea89a77692625037f6470f3c9983b476e27 Mon Sep 17 00:00:00 2001 From: Zac Petit Date: Mon, 16 Feb 2026 16:32:42 -0500 Subject: [PATCH 6/6] style cleanup --- Core/Cache/MinimumAttackerWinCache.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/Core/Cache/MinimumAttackerWinCache.cs b/Core/Cache/MinimumAttackerWinCache.cs index 9d0cf1b..f4d6edd 100644 --- a/Core/Cache/MinimumAttackerWinCache.cs +++ b/Core/Cache/MinimumAttackerWinCache.cs @@ -47,7 +47,6 @@ public static class MinimumAttackerWinCache return Cache.TryGetValue((defenders, roundConfig, balanceConfig), out var minimumAttackers) ? minimumAttackers : Compute(defenders, roundConfig, balanceConfig); - } /// @@ -114,7 +113,6 @@ public static class MinimumAttackerWinCache private static double GetMaxAttackerMultiplier(DiceAugment attackDiceAugment, DiceAugment defenseDiceAugment) { - var multiplier = 1.0; if (defenseDiceAugment.HasFlag(DiceAugment.OnCapital))