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);