Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions Core/Cache/MinimumAttackerWinCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
using System.Collections.Concurrent;

namespace Risk.Dice;

public static class MinimumAttackerWinCache
{
/// <summary>
/// For a particular game, <see cref="RoundConfig"/> and <see cref="BalanceConfig"/>
/// 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.
/// </summary>
private static readonly ConcurrentDictionary<(
int defenders,
RoundConfig round,
BalanceConfig balance), int> Cache
= [];

/// <summary>
/// Gets the minimum number of attackers required to destroy the defending troops with 100% certainty
/// if such a solution exists.
/// </summary>
/// <exception cref="ArgumentNullException">Only supports balanced blitz; balanceConfig required.</exception>
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);
}

/// <summary>
/// Populate the cache for 1..maxDefenders under a particular balanced-blitz setting.
/// Builds a single WinChanceInfo table and scans each column once.
/// </summary>
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);
}
}
8 changes: 8 additions & 0 deletions Core/Config/RoundConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ public struct RoundConfig : IEquatable<RoundConfig>
[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;
Expand All @@ -51,6 +53,9 @@ public struct RoundConfig : IEquatable<RoundConfig>

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
Expand All @@ -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);
Expand Down