From 842acf5dfe2da046a4c3aa1cd90efbfb2bb6aff3 Mon Sep 17 00:00:00 2001 From: Chris Moore Date: Sun, 19 May 2024 08:04:39 -0400 Subject: [PATCH] Add a fast blitz algorithm The algorithms here accomplish two main things: - Replace the "win chance cache" that is currently used in the game to quickly display win chance percentages up to 1000 troops with a much much more efficient set of caches which are both overall smaller and are also able to completely caclculate both win chance and full outcome distributions up to typical floating point accuracy for up to 1000 troops. - Approximate both win chances and outcome distributions for rolls over 1000 troops using a bell curve for the section of the algorithm that actually converges to a bell curve. This can still be computed quickly (about 1 millisecond per 10000 troops on my machine) but is also much more accurate than the approximation algorithm currently in use (individual outcome chances are within 0.01% in the worst cases and the range of all possible outcomes in balanced blitz is almost always off by one troop or less). The new algorithm works by separately computing distributions when both players roll the maximum number of dice and when at least one player is rolling less than the maximum number, and by caching way fewer values for these distributions by ignoring probabilities that are so small that they will get lost in floating point rounding. When at least one player is rolling less than the maximum number of dice we cache outcome distributions in a pretty similar way to the old algorithm, with one full outcome distribution for each attacker/defender pair. However, when keeping the size of one army at a very small fixed size, the distribution of losses as the size of the other army grows very quickly approaches a fixed distribution, so above a certain size of the larger army, just dropping every probability under a given threshold will yield literally the same outcome distribution for every army size. So we only cache results up until this point and can then cache an accurate outcome distribution for every possible outcome with only a fixed size cache. When both players have enough troops to roll the maximum number of dice, they will each roll the same number of dice again and again until one of them loses enough troops that they cannot roll the maximum number of dice. There are a fixed set of possible remaining troop combinations to have when we are done with identical rolls, and we can compute a distribution for the chance on ending on each remaining troop combo from any possible starting number of troops. This computation can be very efficient if we have cached distributions for every possible outcome of running k maximum dice rolls for every value of k. We can then combine this distribution with the distributions of outcome chance for every possible non-max dice roll to get the overall outcome chance distribution. Storing the outcome distribution of k maximum dice rolls for every value of k is not feasible memory wise for very large armies and iteratively computing it is a bit too slow. However this distribution converges to a gaussian distribution with easily computed mean and variance, and by caching the exact results for k up to 1000 we only need to compute distributions that are very close to this gaussian approximation. I tested the new algorithm against every win chance up to 1000 as computed using the old algorithm and these do match (they can be off by around 1 in a million due to floating point rounding but for no matchup does the effect of this rounding make the difference between whether or not the 5% win chance cutoff is applied). The full outcome distribution also matches up to 200 troops but I haven't invested the CPU time to actually compute distributions up to 1000 on the old algorithm. (All of this testing is repeated 7 times when factoring in all modifier combinations that can actually happen in the game.) --- Core/Cache/FastBattleEndCache.cs | 124 ++++++++ Core/Cache/MultiRoundCache.cs | 505 +++++++++++++++++++++++++++++++ Core/Info/BalancedBattleInfo.cs | 6 +- Core/Info/FastBattleEndInfo.cs | 185 +++++++++++ Core/Info/FastBattleInfo.cs | 241 +++++++++++++++ Core/Info/FastWinChanceInfo.cs | 229 ++++++++++++++ 6 files changed, 1287 insertions(+), 3 deletions(-) create mode 100644 Core/Cache/FastBattleEndCache.cs create mode 100644 Core/Cache/MultiRoundCache.cs create mode 100644 Core/Info/FastBattleEndInfo.cs create mode 100644 Core/Info/FastBattleInfo.cs create mode 100644 Core/Info/FastWinChanceInfo.cs diff --git a/Core/Cache/FastBattleEndCache.cs b/Core/Cache/FastBattleEndCache.cs new file mode 100644 index 0000000..9347aab --- /dev/null +++ b/Core/Cache/FastBattleEndCache.cs @@ -0,0 +1,124 @@ +/* + * + * Copyright 2021 SMG Studio. + * + * RISK is a trademark of Hasbro. ©2020 Hasbro.All Rights Reserved.Used under licence. + * + * You are hereby granted a non-exclusive, limited right to use and to install, one (1) copy of the + * software for internal evaluation purposes only and in accordance with the provisions below.You + * may not reproduce, redistribute or publish the software, or any part of it, in any form. + * + * SMG may withdraw this licence without notice and/or request you delete any copies of the software + * (including backups). + * + * The Agreement does not involve any transfer of any intellectual property rights for the + * Software. SMG Studio reserves all rights to the Software not expressly granted in writing to + * you. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT.IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + +using System.Collections.Generic; + +namespace Risk.Dice +{ + public static class FastBattleEndCache + { + private static readonly object _lock = new object(); + + // Every entry in the low attackers cache has the number of attackers + // equal to the number of attacker dice in the RoundConfig and the + // number of defenders equal to one more than the list index + private static Dictionary> _lowAttackersCache = new Dictionary>(new RoundConfigComparer()); + + // Every entry in the low defenders cache has the number of defenders + // equal to the number of defender dice in the RoundConfig and the + // number of attackers equal to one more than the list index + private static Dictionary> _lowDefendersCache = new Dictionary>(new RoundConfigComparer()); + + // Returns a fully calculated FastBattleEndInfo. At most one of the + // Unit counts in the BattleConfig may exceed the matching dice count + // in the RoundConfig. + public static FastBattleEndInfo Get (RoundConfig roundConfig, BattleConfig battleConfig) + { + FastBattleEndInfo battleInfo = default; + + lock (_lock) + { + battleInfo = GetUnlocked(roundConfig, battleConfig); + } + + return battleInfo; + } + + // Meant to be called from within another call to Get or GetUnlocked + public static FastBattleEndInfo GetUnlocked (RoundConfig roundConfig, BattleConfig battleConfig) + { + int attackUnitCount = battleConfig.AttackUnitCount; + int defendUnitCount = battleConfig.DefendUnitCount; + + roundConfig = roundConfig.WithBattle(battleConfig); + + if (attackUnitCount > defendUnitCount) + { + if (!_lowDefendersCache.ContainsKey(roundConfig)) + { + _lowDefendersCache[roundConfig] = new List(); + } + + List battleInfoList = _lowDefendersCache[roundConfig]; + + while (battleInfoList.Count < attackUnitCount) + { + if (battleInfoList.Count > 0 && !battleInfoList[battleInfoList.Count - 1].UseAllAttackers) + { + return battleInfoList[battleInfoList.Count - 1]; + } + BattleConfig nextBattleConfig = battleConfig.WithNewUnits(battleInfoList.Count + 1, defendUnitCount); + FastBattleEndInfo nextBattleInfo = new FastBattleEndInfo(nextBattleConfig, roundConfig); + nextBattleInfo.Calculate(); + battleInfoList.Add(nextBattleInfo); + } + + return battleInfoList[attackUnitCount - 1]; + } + else + { + if (!_lowAttackersCache.ContainsKey(roundConfig)) + { + _lowAttackersCache[roundConfig] = new List(); + } + + List battleInfoList = _lowAttackersCache[roundConfig]; + + while (battleInfoList.Count < defendUnitCount) + { + if (battleInfoList.Count > 0 && !battleInfoList[battleInfoList.Count - 1].UseAllDefenders) + { + return battleInfoList[battleInfoList.Count - 1]; + } + BattleConfig nextBattleConfig = battleConfig.WithNewUnits(attackUnitCount, battleInfoList.Count + 1); + FastBattleEndInfo nextBattleInfo = new FastBattleEndInfo(nextBattleConfig, roundConfig); + nextBattleInfo.Calculate(); + battleInfoList.Add(nextBattleInfo); + } + + return battleInfoList[defendUnitCount - 1]; + } + } + + public static void Clear () + { + lock (_lock) + { + _lowDefendersCache.Clear(); + _lowAttackersCache.Clear(); + } + } + } +} diff --git a/Core/Cache/MultiRoundCache.cs b/Core/Cache/MultiRoundCache.cs new file mode 100644 index 0000000..61baaa0 --- /dev/null +++ b/Core/Cache/MultiRoundCache.cs @@ -0,0 +1,505 @@ +/* + * + * Copyright 2021 SMG Studio. + * + * RISK is a trademark of Hasbro. ©2020 Hasbro.All Rights Reserved.Used under licence. + * + * You are hereby granted a non-exclusive, limited right to use and to install, one (1) copy of the + * software for internal evaluation purposes only and in accordance with the provisions below.You + * may not reproduce, redistribute or publish the software, or any part of it, in any form. + * + * SMG may withdraw this licence without notice and/or request you delete any copies of the software + * (including backups). + * + * The Agreement does not involve any transfer of any intellectual property rights for the + * Software. SMG Studio reserves all rights to the Software not expressly granted in writing to + * you. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT.IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + +using System; +using System.Collections.Generic; + +namespace Risk.Dice +{ + // Data holder for a distribution of losses. This can represent a + // distribution of attacker losses for a fixed number of defender losses, + // in which case the attacker losses increase by roundConfig.ChallengeCount + // as the array index increases by 1, or it can represent a distribution of + // defender losses for a fixed number of attacker losses, in which case the + // defender losses increase by roundConfig.ChallengeCount as the array index + // increases by 1. In both cases initialLoss refers to the loss at array + // index zero. Also in both cases this is not necessarily a complete + // distribution and the sum of all outcomeChances here may be less than one. + // + // Internal to MultiRoundCacheInfo we also use this for a distribution of + // losses over a fixed number of rounds. In this use case initialLoss is + // the number of attacker troops lost at array index zero and each increment + // of the array index indicates one fewer attacker troop lost and one more + // defender troop lost. + public class MultiRoundLossInfo + { + public int InitialLoss; + public double[] OutcomeChances; + public MultiRoundLossInfo (int initialLoss, double[] outcomeChances) + { + InitialLoss = initialLoss; + OutcomeChances = outcomeChances; + } + } + + // Holder for all of the information about multiple rounds of one single RoundConfig + public class MultiRoundCacheInfo + { + private RoundInfo _roundInfo; + + // List index i represents losses after i rounds + private List _losses; + + // Cutoff to determine when odds are too small to affect calculations + private const double _oddsCutoff = 1e-16; + + // Cutoff for the number of rounds where we compute exactly instead of + // using a gaussian approximation + private const int _maxRounds = 1000; + + // Precomputed variables for gaussian approximation + private double _roundMean; + private double _roundVar; + private double _logCutoff; + + public int ChallengeCount => _roundInfo.Config.ChallengeCount; + + public MultiRoundCacheInfo (RoundConfig roundConfig) + { + _roundInfo = RoundCache.Get(roundConfig); + _roundInfo.Calculate(); + _losses = new List(); + MultiRoundLossInfo nullLosses = new MultiRoundLossInfo(0, new double[1]); + nullLosses.OutcomeChances[0] = 1; + _losses.Add(nullLosses); + _roundMean = 0; + double squaresMean = 0; + for (int i = 1; i < _roundInfo.AttackLossChances.Length; i++) + { + _roundMean += i * _roundInfo.AttackLossChances[i]; + squaresMean += i * i * _roundInfo.AttackLossChances[i]; + } + _roundVar = squaresMean - _roundMean * _roundMean; + _logCutoff = -Math.Log(2 * _oddsCutoff * _oddsCutoff * Math.PI * _roundVar); + } + + // Given a distribution of loss info for N rounds, compute and return + // the distribution of loss info for N+1 rounds. + private MultiRoundLossInfo GetNext (MultiRoundLossInfo lastLossInfo) + { + int initialLossIncrease = 0; + int finalLossIncrease = ChallengeCount; + + // Determine how few troops the attackers can lose while keeping the + // odds of losing so few troops above the odds cutoff. + while (initialLossIncrease < ChallengeCount) + { + double startOdds = 0; + for (int i = 0; i <= initialLossIncrease; i++) + { + startOdds += lastLossInfo.OutcomeChances[initialLossIncrease - i] * _roundInfo.AttackLossChances[i]; + } + if (startOdds > _oddsCutoff) + { + break; + } + initialLossIncrease++; + } + + // Determine how few troops the defenders can lose while keeping the + // odds of losing so few troops above the odds cutoff. + while (finalLossIncrease > 0) + { + double endOdds = 0; + int outcomeOffset = lastLossInfo.OutcomeChances.Length - 1; + for (int i = finalLossIncrease; i <= ChallengeCount; i++) + { + endOdds += lastLossInfo.OutcomeChances[outcomeOffset + finalLossIncrease - i] * _roundInfo.AttackLossChances[i]; + } + if (endOdds > _oddsCutoff) + { + break; + } + finalLossIncrease--; + } + + double[] outcomeChances = new double[lastLossInfo.OutcomeChances.Length - initialLossIncrease + finalLossIncrease]; + + // Populate outcome chances for the next round + for (int i = 0; i < outcomeChances.Length; i++) + { + for (int a = 0; a <= ChallengeCount; a++) + { + int j = i - a + initialLossIncrease; + if (j >= lastLossInfo.OutcomeChances.Length) + { + continue; + } + if (j < 0) + { + break; + } + outcomeChances[i] += lastLossInfo.OutcomeChances[j] * _roundInfo.AttackLossChances[a]; + } + } + + int initialLoss = lastLossInfo.InitialLoss + initialLossIncrease; + return new MultiRoundLossInfo(initialLoss, outcomeChances); + } + + // Return whether any of the nonzero outcome chances in the given round + // have losses strictly less than both limits at the same time. + private bool CanCoverLosses (int round, int attackerLoss, int defenderLoss) + { + if (round * ChallengeCount > attackerLoss + defenderLoss - 2) + { + return false; + } + MultiRoundLossInfo lossInfo = _losses[round]; + if (lossInfo.InitialLoss >= attackerLoss) + { + return false; + } + if (round * ChallengeCount - lossInfo.InitialLoss - lossInfo.OutcomeChances.Length + 1 >= defenderLoss) + { + return false; + } + return true; + } + + // Find a round number where the odds of the given attacker or + // defender loss are (according to gaussian approximation) exactly our + // odds cutoff. Passing which = 1 will give a round where the more + // likely losses are higher and which = -1 will give a round where the + // more likely losses are lower. + private double gaussianLossRound (int loss, bool isAttacker, int which) { + double lossMean = isAttacker? _roundMean : ChallengeCount - _roundMean; + double round = loss / lossMean; + double b = 2 * loss * lossMean; + double scale = 0.5 / (lossMean * lossMean); + for (int i = 0; i < 2; i++) + { + double d = (_logCutoff - Math.Log(round)) * _roundVar; + round = scale * (b + d + which * Math.Sqrt(d * (2 * b + d))); + } + return round; + } + + // Return the last round number where any outcome in that round has + // losses strictly less than both limits at the same time AND the + // odds of such an outcome is more than our cutoff. + private int lastRelevantGaussianRound (int attackerLoss, int defenderLoss) + { + double round = Math.Min(gaussianLossRound(attackerLoss, true, 1), gaussianLossRound(defenderLoss, false, 1)); + return Math.Min((int)(round + 1), (attackerLoss + defenderLoss - 2) / ChallengeCount); + } + + // Return the last round number where CanCoverLosses is true for that + // round. We cache more round losses as needed to make sure we actually + // have loss info for the returned round. + private int lastRelevantRound (int attackerLoss, int defenderLoss) + { + int round = -1; + + // First we make sure that we have enough rounds cached. If we + // cache up to our maximum size and don't have enough we just return + // the round number according to gaussian approximation. + while (CanCoverLosses(_losses.Count - 1, attackerLoss, defenderLoss)) + { + if (_losses.Count > _maxRounds) + { + return lastRelevantGaussianRound(attackerLoss, defenderLoss); + } + round = _losses.Count - 1; + _losses.Add(GetNext(_losses[_losses.Count - 1])); + } + + // If we added rounds then we already know where the last round is + if (round >= 0) + { + return round; + } + + // Otherwise we bisect to find the round + int bottom = 0; + int top = _losses.Count - 1; + while (top > bottom + 1) + { + int middle = (top + bottom) / 2; + if (CanCoverLosses(middle, attackerLoss, defenderLoss)) + { + bottom = middle; + } + else + { + top = middle; + } + } + return bottom; + } + + // Return distributions of defender losses that we expect to see if we + // roll until we reach or exceed the given attacker loss count and then + // immediately stop rolling. We return a list of distributions for each + // attacker loss at or above our limit that we might end up at; each + // index i in this list corresponds to an loss of attackerLoss + i. + // + // The defenderLoss parameter means that we also stop rolling if we reach + // or exceed this number of lost defenders. If we hit this limit on the + // same round that we hit the attacker limit then this is included in the + // returned distributions and otherwise it is not. + public List GetFixedAttackerLoss (int attackerLoss, int defenderLoss) + { + int lastRound = lastRelevantRound(attackerLoss, defenderLoss); + int lastCachedRound = Math.Min(lastRound, _maxRounds); + MultiRoundLossInfo lastLossInfo = _losses[lastCachedRound]; + + // Find the first round that gets within one round of our attacker + // loss limit. If it is in our cache we bisect to find it and + // otherwise we compute it. This may compute to less than our last + // relevant round, indicating that there are actually no relevant + // rounds and we are just done. + int firstRound; + int maxAttackerLoss = lastLossInfo.InitialLoss + lastLossInfo.OutcomeChances.Length - 1; + if (maxAttackerLoss + ChallengeCount < attackerLoss) + { + if (lastRound == lastCachedRound) + { + return new List(); + } + firstRound = (int)gaussianLossRound(attackerLoss - ChallengeCount - 1, true, -1); + if (firstRound > lastRound) + { + return new List(); + } + } + else + { + int bottom = -1; + firstRound = lastCachedRound; + while (firstRound > bottom + 1) + { + int middle = (firstRound + bottom) / 2; + MultiRoundLossInfo middleLossInfo = _losses[middle]; + maxAttackerLoss = middleLossInfo.InitialLoss + middleLossInfo.OutcomeChances.Length - 1; + if (maxAttackerLoss + ChallengeCount < attackerLoss) + { + bottom = middle; + } + else + { + firstRound = middle; + } + } + } + + // Allocate loss distributions + List result = new List(ChallengeCount); + int baseInitialLoss = (lastRound + 1) * ChallengeCount - attackerLoss; + for (int a = 0; a < ChallengeCount; a++) + { + result.Add(new MultiRoundLossInfo(baseInitialLoss - a, new double[lastRound - firstRound + 1])); + } + + // For each round, for each outcome in that round that gets us + // within one round of our loss limit and for each single round + // outcome that puts us over that limit, accumulate the overall + // odds into our output loss distribution. We do this separately + // for each round from the cache and for each round that we + // approximate with a gaussian. + for (int round = firstRound; round <= lastCachedRound; round++) + { + MultiRoundLossInfo lossInfo = _losses[round]; + int lossOffset = attackerLoss - lossInfo.InitialLoss; + int lossStart = Math.Max(1, lossOffset - lossInfo.OutcomeChances.Length + 1); + int lossEnd = Math.Min(ChallengeCount, lossOffset); + lossEnd = Math.Min(lossEnd, defenderLoss + lossOffset + lossInfo.InitialLoss - round * ChallengeCount - 1); + for (int a = lossStart; a <= lossEnd; a++) + { + double outcomeChance = lossInfo.OutcomeChances[lossOffset - a]; + for (int i = a; i <= ChallengeCount; i++) + { + result[i-a].OutcomeChances[lastRound-round] += _roundInfo.AttackLossChances[i] * outcomeChance; + } + } + } + for (int round = Math.Max(firstRound, lastCachedRound + 1); round <= lastRound; round++) + { + double mean = attackerLoss - _roundMean * round; + double var = _roundVar * round; + double scale = Math.Pow(2 * var * Math.PI, -0.5); + double expScale = -0.5 / var; + int lossEnd = Math.Min(ChallengeCount, defenderLoss + attackerLoss - round * ChallengeCount - 1); + for (int a = 1; a <= lossEnd; a++) + { + double deviation = mean - a; + double outcomeChance = scale * Math.Exp(expScale * deviation * deviation); + if (outcomeChance < _oddsCutoff) + { + continue; + } + for (int i = a; i <= ChallengeCount; i++) + { + result[i-a].OutcomeChances[lastRound-round] += _roundInfo.AttackLossChances[i] * outcomeChance; + } + } + } + + return result; + } + + // Return distributions of attacker losses that we expect to see if we + // roll until we reach or exceed the given defender loss count and then + // immediately stop rolling. We return a list of distributions for each + // defender loss at or above our limit that we might end up at; each + // index i in this list corresponds to an loss of defenderLoss + i. + // + // The attackerLoss parameter means that we also stop rolling if we reach + // or exceed this number of lost attackers. If we hit this limit on the + // same round that we hit the defender limit then this is included in the + // returned distributions and otherwise it is not. + public List GetFixedDefenderLoss (int attackerLoss, int defenderLoss) + { + int lastRound = lastRelevantRound(attackerLoss, defenderLoss); + int lastCachedRound = Math.Min(lastRound, _maxRounds); + MultiRoundLossInfo lastLossInfo = _losses[lastCachedRound]; + + // Find the first round that gets within one round of our defender + // loss limit. If it is in our cache we bisect to find it and + // otherwise we compute it. This may compute to less than our last + // relevant round, indicating that there are actually no relevant + // rounds and we are just done. + int firstRound; + int maxDefenderLoss = lastRound * ChallengeCount - lastLossInfo.InitialLoss; + if (maxDefenderLoss + ChallengeCount < defenderLoss) + { + if (lastRound == lastCachedRound) + { + return new List(); + } + firstRound = (int)gaussianLossRound(defenderLoss - ChallengeCount - 1, false, -1); + if (firstRound > lastRound) + { + return new List(); + } + } + else + { + int bottom = -1; + firstRound = lastCachedRound; + while (firstRound > bottom + 1) + { + int middle = (firstRound + bottom) / 2; + MultiRoundLossInfo middleLossInfo = _losses[middle]; + maxDefenderLoss = middle * ChallengeCount - middleLossInfo.InitialLoss; + if (maxDefenderLoss + ChallengeCount < defenderLoss) + { + bottom = middle; + } + else + { + firstRound = middle; + } + } + } + + // Allocate loss distributions + List result = new List(ChallengeCount); + int baseInitialLoss = (firstRound + 1) * ChallengeCount - defenderLoss; + for (int a = 0; a < ChallengeCount; a++) + { + result.Add(new MultiRoundLossInfo(baseInitialLoss - a, new double[lastRound - firstRound + 1])); + } + + // For each round, for each outcome in that round that gets us + // within one round of our loss limit and for each single round + // outcome that puts us over that limit, accumulate the overall + // odds into our output loss distribution. We do this separately + // for each round from the cache and for each round that we + // approximate with a gaussian. + for (int round = firstRound; round <= lastCachedRound; round++) + { + MultiRoundLossInfo lossInfo = _losses[round]; + int lossOffset = (round + 1) * ChallengeCount - lossInfo.InitialLoss - defenderLoss; + int lossStart = Math.Max(0, lossOffset - lossInfo.OutcomeChances.Length + 1); + int lossEnd = Math.Min(ChallengeCount - 1, lossOffset); + lossStart = Math.Max(lossStart, lossOffset + lossInfo.InitialLoss + 1 - attackerLoss); + for (int a = lossStart; a <= lossEnd; a++) + { + double outcomeChance = lossInfo.OutcomeChances[lossOffset - a]; + for (int i = 0; i <= a; i++) + { + result[a-i].OutcomeChances[round-firstRound] += _roundInfo.AttackLossChances[i] * outcomeChance; + } + } + } + for (int round = Math.Max(firstRound, lastCachedRound + 1); round <= lastRound; round++) + { + int roundLoss = (round + 1) * ChallengeCount; + double mean = roundLoss - defenderLoss - _roundMean * round; + double var = _roundVar * round; + double scale = Math.Pow(2 * var * Math.PI, -0.5); + double expScale = -0.5 / var; + int lossStart = Math.Max(0, roundLoss + 1 - attackerLoss - defenderLoss); + for (int a = lossStart; a < ChallengeCount; a++) + { + double deviation = mean - a; + double outcomeChance = scale * Math.Exp(expScale * deviation * deviation); + if (outcomeChance < _oddsCutoff) + { + continue; + } + for (int i = 0; i <= a; i++) + { + result[a-i].OutcomeChances[round-firstRound] += _roundInfo.AttackLossChances[i] * outcomeChance; + } + } + } + + return result; + } + } + + public static class MultiRoundCache + { + private static readonly object _lock = new object(); + private static Dictionary _cache = new Dictionary(new RoundConfigComparer()); + + public static MultiRoundCacheInfo Get (RoundConfig roundConfig) + { + MultiRoundCacheInfo multiRoundCacheInfo = default; + + lock (_lock) + { + if (!_cache.ContainsKey(roundConfig)) + { + _cache[roundConfig] = new MultiRoundCacheInfo(roundConfig); + } + + multiRoundCacheInfo = _cache[roundConfig]; + } + + return multiRoundCacheInfo; + } + + public static void Clear () + { + lock (_lock) + { + _cache.Clear(); + } + } + } +} diff --git a/Core/Info/BalancedBattleInfo.cs b/Core/Info/BalancedBattleInfo.cs index 8ba0d49..36b9a37 100644 --- a/Core/Info/BalancedBattleInfo.cs +++ b/Core/Info/BalancedBattleInfo.cs @@ -36,14 +36,14 @@ namespace Risk.Dice { [Serializable] - public sealed class BalancedBattleInfo : BattleInfo + public sealed class BalancedBattleInfo : FastBattleInfo { [SerializeField] private BalanceConfig _balanceConfig; [SerializeField] private bool _balanceApplied; public override bool IsReady => _balanceApplied && base.IsReady; - public BalancedBattleInfo (BattleInfo battleInfo, BalanceConfig balanceConfig) : base(battleInfo.BattleConfig, battleInfo.RoundConfig) + public BalancedBattleInfo (FastBattleInfo battleInfo, BalanceConfig balanceConfig) : base(battleInfo.BattleConfig, battleInfo.RoundConfig) { _balanceConfig = balanceConfig; @@ -379,4 +379,4 @@ private void ApplyOutcomePower () } } } -} \ No newline at end of file +} diff --git a/Core/Info/FastBattleEndInfo.cs b/Core/Info/FastBattleEndInfo.cs new file mode 100644 index 0000000..77b0a49 --- /dev/null +++ b/Core/Info/FastBattleEndInfo.cs @@ -0,0 +1,185 @@ +/* + * + * Copyright 2021 SMG Studio. + * + * RISK is a trademark of Hasbro. ©2020 Hasbro.All Rights Reserved.Used under licence. + * + * You are hereby granted a non-exclusive, limited right to use and to install, one (1) copy of the + * software for internal evaluation purposes only and in accordance with the provisions below.You + * may not reproduce, redistribute or publish the software, or any part of it, in any form. + * + * SMG may withdraw this licence without notice and/or request you delete any copies of the software + * (including backups). + * + * The Agreement does not involve any transfer of any intellectual property rights for the + * Software. SMG Studio reserves all rights to the Software not expressly granted in writing to + * you. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT.IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + +using System; +using System.Collections; +using Risk.Dice.Utility; +using UnityEngine; + +#if UNITY_ASSERTIONS +using UnityEngine.Assertions; +#endif + +namespace Risk.Dice +{ + [Serializable] + public class FastBattleEndInfo + { + [SerializeField] protected BattleConfig _battleConfig; + [SerializeField] protected RoundConfig _roundConfig; + [SerializeField] protected double[] _outcomeChances; + [SerializeField] protected double _winChance; + [SerializeField] protected bool _useAllAttackers; + [SerializeField] protected bool _useAllDefenders; + + // Note that when you retrieve this object from FastBattleEndCache.Get, + // you might not get a battle config here that has all of the attackers + // or all of the defenders from the original battle config + public BattleConfig BattleConfig => _battleConfig; + + public RoundConfig RoundConfig => _roundConfig; + + // When this is set the number of attackers in the battle config here + // will always match the requested battle config and the last value in + // OutcomeChances will represent losing all attackers and no defenders + // When this is not set, the number of attackers in the battle config + // here may be less than the number requested, and every entry in + // OutcomeChances will represent losing all defenders with the first + // representing losing no attackers + public bool UseAllAttackers => _useAllAttackers; + + // When this is set the number of defenders in the battle config here + // will always match the requested battle config and the first value in + // OutcomeChances will represent losing all defenders and no attackers + // When this is not set, the number of defenders in the battle config + // here may be less than the number requested, and every entry in + // OutcomeChances will represent losing all attackers with the last + // representing losing no defenders + public bool UseAllDefenders => _useAllDefenders; + + // The beginning of this array represents the best possible outcome for + // the attacker and the end represents the best possible outcome for the + // defender. The exact meaning of each entry depends on UseAllAttackers + // and UseAllDefenders but each entry always represents a total loss for + // exact one of the attacker or defender and stepping forward in the + // array always either increases the attackers losses by 1 or decreases + // the defenders losses by 1. + public double[] OutcomeChances => _outcomeChances; + + public double WinChance => _winChance; + + public virtual bool IsReady => _outcomeChances != null && _outcomeChances.Length > 0; + + // Cutoff to determine when odds are too small to affect calculations + protected const double _oddsCutoff = 1e-16; + + public FastBattleEndInfo (BattleConfig battleConfig, RoundConfig roundConfig) + { + _battleConfig = battleConfig; + _roundConfig = roundConfig; + _useAllAttackers = true; + _useAllDefenders = true; + } + + // This is only expected to be called from FastBattleEndCache.Get() + public void Calculate () + { + if (IsReady) + { + return; + } + + int attackUnitCount = _battleConfig.AttackUnitCount; + int defendUnitCount = _battleConfig.DefendUnitCount; + + double[] outcomeChances = new double[attackUnitCount + defendUnitCount]; + + RoundConfig roundConfig = _roundConfig.WithBattle(_battleConfig); + RoundInfo roundInfo = RoundCache.Get(roundConfig); + roundInfo.Calculate(); + + for (int i = 0; i < roundInfo.AttackLossChances.Length; i++) + { + double roundChance = roundInfo.AttackLossChances[i]; + + if (roundChance <= 0.0) + { + continue; + } + + int remainingAttackUnitCount = attackUnitCount - i; + int remainingDefendUnitCount = defendUnitCount - (roundConfig.ChallengeCount - i); + + if (remainingAttackUnitCount <= 0) + { + outcomeChances[outcomeChances.Length - 1] += roundChance; + } + else if (remainingDefendUnitCount <= 0) + { + outcomeChances[0] += roundChance; + } + else + { + // Battle chain continues + BattleConfig nextBattleConfig = _battleConfig.WithNewUnits(remainingAttackUnitCount, remainingDefendUnitCount); + FastBattleEndInfo battleInfo = FastBattleEndCache.GetUnlocked(roundConfig, nextBattleConfig); + int offset = battleInfo._useAllDefenders? 0 : remainingAttackUnitCount + remainingDefendUnitCount - battleInfo._outcomeChances.Length; + + for (int a = 0; a < battleInfo._outcomeChances.Length; a++) + { + outcomeChances[i + a + offset] += roundChance * battleInfo._outcomeChances[a]; + } + } + } + + // Check if we will always get the same results if we add more of + // whichever side is already ahead. When attackers are ahead, the + // odds of any attacker loss that still leaves the attacker with + // troops equal to the maximum attacker dice roll will not change, + // and when defenders are ahead, the odds of any defender loss that + // still leaves the defender with troops equal to the maximum + // defender dice roll will not change. + if (attackUnitCount > defendUnitCount && outcomeChances[attackUnitCount - roundConfig.AttackDiceCount] < _oddsCutoff) + { + _winChance = 1; + _outcomeChances = new double[attackUnitCount - roundConfig.AttackDiceCount]; + Array.Copy(outcomeChances, _outcomeChances, attackUnitCount - roundConfig.AttackDiceCount); + _useAllAttackers = false; + } + else if (attackUnitCount < defendUnitCount && outcomeChances[attackUnitCount - 1 + roundConfig.DefendDiceCount] < _oddsCutoff) + { + _winChance = 0; + _outcomeChances = new double[defendUnitCount - roundConfig.DefendDiceCount]; + Array.Copy(outcomeChances, attackUnitCount + roundConfig.DefendDiceCount, _outcomeChances, 0, defendUnitCount - roundConfig.DefendDiceCount); + _useAllDefenders = false; + } + else + { + double winChance = 0; + for (int i = 0; i < attackUnitCount; i++) + { + winChance += outcomeChances[i]; + } + _winChance = winChance; + _outcomeChances = outcomeChances; + } + +#if UNITY_ASSERTIONS + Assert.AreApproximatelyEqual((float) _outcomeChances.SumAsDouble(), (float) 1.0); +#endif + } + + } +} diff --git a/Core/Info/FastBattleInfo.cs b/Core/Info/FastBattleInfo.cs new file mode 100644 index 0000000..9023b9b --- /dev/null +++ b/Core/Info/FastBattleInfo.cs @@ -0,0 +1,241 @@ +/* + * + * Copyright 2021 SMG Studio. + * + * RISK is a trademark of Hasbro. ©2020 Hasbro.All Rights Reserved.Used under licence. + * + * You are hereby granted a non-exclusive, limited right to use and to install, one (1) copy of the + * software for internal evaluation purposes only and in accordance with the provisions below.You + * may not reproduce, redistribute or publish the software, or any part of it, in any form. + * + * SMG may withdraw this licence without notice and/or request you delete any copies of the software + * (including backups). + * + * The Agreement does not involve any transfer of any intellectual property rights for the + * Software. SMG Studio reserves all rights to the Software not expressly granted in writing to + * you. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT.IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + +using System; +using System.Collections.Generic; +using Risk.Dice.Utility; +using UnityEngine; + +#if UNITY_ASSERTIONS +using UnityEngine.Assertions; +#endif + +namespace Risk.Dice +{ + [Serializable] + public class FastBattleInfo + { + [SerializeField] protected BattleConfig _battleConfig; + [SerializeField] protected RoundConfig _roundConfig; + [SerializeField] protected double[] _attackLossChances; + [SerializeField] protected double[] _defendLossChances; + + public BattleConfig BattleConfig => _battleConfig; + public RoundConfig RoundConfig => _roundConfig; + public double[] AttackLossChances => _attackLossChances; + public double[] DefendLossChances => _defendLossChances; + public double AttackWinChance => _defendLossChances[_battleConfig.DefendUnitCount]; + public double DefendWinChance => _attackLossChances[_battleConfig.AttackUnitCount]; + public double UnresolvedChance => _battleConfig.StopUntil > 0 ? Math.Max(1.0 - AttackWinChance - DefendWinChance, 0.0) : 0.0; + public virtual bool IsReady => _attackLossChances != null && _defendLossChances != null && _attackLossChances.Length > 0 && _defendLossChances.Length > 0; + + internal FastBattleInfo (RoundConfig roundConfig) + { + _roundConfig = roundConfig; + } + + public FastBattleInfo (BattleConfig battleConfig, RoundConfig roundConfig) + { + _battleConfig = battleConfig; + _roundConfig = roundConfig; + } + + private void AddEndChances(int attackLoss, int defendLoss, double scale) + { + int attackUnitCount = _battleConfig.AttackUnitCount; + int defendUnitCount = _battleConfig.DefendUnitCount; + int remainingAttackers = attackUnitCount - attackLoss; + int remainingDefenders = defendUnitCount - defendLoss; + + BattleConfig battleConfig = new BattleConfig(remainingAttackers, remainingDefenders, 0); + FastBattleEndInfo endInfo = FastBattleEndCache.Get(_roundConfig, battleConfig); + int length = endInfo.OutcomeChances.Length; + if (!endInfo.UseAllAttackers) + { + for (int i = 0; i < length; i++) + { + _attackLossChances[attackLoss + i] += endInfo.OutcomeChances[i] * scale; + } + } + else if (!endInfo.UseAllDefenders) + { + for (int i = 0; i < length; i++) + { + _defendLossChances[defendLoss + i] += endInfo.OutcomeChances[length - 1 - i] * scale; + } + } + else + { + for (int i = 0; i < remainingAttackers; i++) + { + _attackLossChances[attackLoss + i] += endInfo.OutcomeChances[i] * scale; + } + for (int i = 0; i < remainingDefenders; i++) + { + _defendLossChances[defendLoss + i] += endInfo.OutcomeChances[length - 1 - i] * scale; + } + } + } + + public virtual void Calculate () + { + if (IsReady) + { + return; + } + + int attackUnitCount = _battleConfig.AttackUnitCount; + int defendUnitCount = _battleConfig.DefendUnitCount; + int stopUntil = _battleConfig.StopUntil; + + _attackLossChances = new double[attackUnitCount + 1]; + _defendLossChances = new double[defendUnitCount + 1]; + + if (stopUntil != 0) + { + BattleConfig baseBattleConfig = _battleConfig.WithoutStopUntil(); + FastBattleInfo baseBattleInfo = new FastBattleInfo(baseBattleConfig, _roundConfig); + baseBattleInfo.Calculate(); + + for (int i = 0; i < _attackLossChances.Length; i++) + { + if (i < baseBattleInfo._attackLossChances.Length - 1) + { + _attackLossChances[i] = baseBattleInfo._attackLossChances[i]; + } + } + + for (int i = 0; i < _defendLossChances.Length; i++) + { + _defendLossChances[i] = baseBattleInfo._defendLossChances[i]; + } + } + else if (attackUnitCount < _roundConfig.AttackDiceCount || defendUnitCount < _roundConfig.DefendDiceCount) + { + AddEndChances(0, 0, 1); + _defendLossChances[defendUnitCount] = MathUtil.SumAsDouble(_attackLossChances, 0, attackUnitCount); + _attackLossChances[attackUnitCount] = MathUtil.SumAsDouble(_defendLossChances, 0, defendUnitCount); + } + else + { + MultiRoundCacheInfo multiRoundCacheInfo = MultiRoundCache.Get(_roundConfig); + int attackerLossTarget = attackUnitCount - _roundConfig.AttackDiceCount + 1; + int defenderLossTarget = defendUnitCount - _roundConfig.DefendDiceCount + 1; + + // Go through every case where the defenders fall below their + // maximum dice roll before or at the same time as the attackers + // fall below their maximum dice roll. + List attackerLosses = multiRoundCacheInfo.GetFixedDefenderLoss(attackerLossTarget, defenderLossTarget); + for (int i = 0; i < attackerLosses.Count; ++i) { + int defenderLoss = defenderLossTarget + i; + int attackerLoss = attackerLosses[i].InitialLoss; + double[] outcomeChances = attackerLosses[i].OutcomeChances; + int challengeCount = _roundConfig.ChallengeCount; + for (int j = 0; j < outcomeChances.Length; j++, attackerLoss += challengeCount) + { + double outcomeChance = outcomeChances[j]; + if (outcomeChance <= 0.0) + { + continue; + } + if (defenderLoss == defendUnitCount) + { + _attackLossChances[attackerLoss] += outcomeChance; + } + else + { + AddEndChances(attackerLoss, defenderLoss, outcomeChance); + } + } + } + + // Go through every case where the attackers fall below their + // maximum dice roll strictly before the attackers fall below + // maximum dice roll. GetFixedAttackerLoss does include cases + // where both fall below the limit at the same time so we do + // explicitly filter these out here. + List defenderLosses = multiRoundCacheInfo.GetFixedAttackerLoss(attackerLossTarget, defenderLossTarget); + for (int i = 0; i < defenderLosses.Count; ++i) { + int attackerLoss = attackerLossTarget + i; + int defenderLoss = defenderLosses[i].InitialLoss; + double[] outcomeChances = defenderLosses[i].OutcomeChances; + int challengeCount = _roundConfig.ChallengeCount; + for (int j = 0; j < outcomeChances.Length; j++, defenderLoss -= challengeCount) + { + double outcomeChance = outcomeChances[j]; + if (outcomeChance <= 0.0 || defenderLoss >= defenderLossTarget) + { + continue; + } + if (attackerLoss == attackUnitCount) + { + _defendLossChances[defenderLoss] += outcomeChance; + } + else + { + AddEndChances(attackerLoss, defenderLoss, outcomeChance); + } + } + } + + // Calcualte over win and loss chances to completely populate + // the attack and defend loss chances. Also if we used any + // gaussian approximation our odds might not quite sum to one + // anymore so we renormalize here. + double winChance = MathUtil.SumAsDouble(_attackLossChances, 0, attackUnitCount); + double lossChance = MathUtil.SumAsDouble(_defendLossChances, 0, defendUnitCount); + double normalizationRatio = 1.0 / (winChance + lossChance); + winChance *= normalizationRatio; + lossChance *= normalizationRatio; + MathUtil.NormalizeSum(_attackLossChances, winChance, 0, attackUnitCount); + MathUtil.NormalizeSum(_defendLossChances, lossChance, 0, defendUnitCount); + _defendLossChances[defendUnitCount] = winChance; + _attackLossChances[attackUnitCount] = lossChance; + } + +#if UNITY_ASSERTIONS + Assert.AreApproximatelyEqual((float) (AttackWinChance + DefendWinChance + UnresolvedChance), (float) 1.0); + Assert.AreApproximatelyEqual((float) _attackLossChances.SumAsDouble() + (float) UnresolvedChance, (float) 1.0); + Assert.AreApproximatelyEqual((float) _defendLossChances.SumAsDouble(), (float) 1.0); +#endif + } + + public virtual double GetOutcomeChance (int lostAttackCount, int lostDefendCount) + { + if (lostAttackCount == _battleConfig.AttackUnitCount - _battleConfig.StopUntil) + { + return _attackLossChances[lostAttackCount]; + } + else if (lostDefendCount == _battleConfig.DefendUnitCount) + { + return _defendLossChances[lostDefendCount]; + } + else + { + return -1; + } + } + } +} diff --git a/Core/Info/FastWinChanceInfo.cs b/Core/Info/FastWinChanceInfo.cs new file mode 100644 index 0000000..0234fea --- /dev/null +++ b/Core/Info/FastWinChanceInfo.cs @@ -0,0 +1,229 @@ +/* + * + * Copyright 2021 SMG Studio. + * + * RISK is a trademark of Hasbro. ©2020 Hasbro.All Rights Reserved.Used under licence. + * + * You are hereby granted a non-exclusive, limited right to use and to install, one (1) copy of the + * software for internal evaluation purposes only and in accordance with the provisions below.You + * may not reproduce, redistribute or publish the software, or any part of it, in any form. + * + * SMG may withdraw this licence without notice and/or request you delete any copies of the software + * (including backups). + * + * The Agreement does not involve any transfer of any intellectual property rights for the + * Software. SMG Studio reserves all rights to the Software not expressly granted in writing to + * you. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT.IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + +using System; +using System.Collections.Generic; +using Risk.Dice.Utility; +using UnityEngine; + +#if UNITY_ASSERTIONS +using UnityEngine.Assertions; +#endif + +namespace Risk.Dice +{ + [Serializable] + public class FastWinChanceInfo + { + [SerializeField] private RoundConfig _roundConfig; + [SerializeField] private BalanceConfig _balanceConfig; + + public RoundConfig RoundConfig => _roundConfig; + public BalanceConfig BalanceConfig => _balanceConfig; + + public FastWinChanceInfo () : this(RoundConfig.Default, null) + { + } + + public FastWinChanceInfo (RoundConfig roundConfig) : this (roundConfig, null) + { + } + + public FastWinChanceInfo (RoundConfig roundConfig, BalanceConfig balanceConfig) + { + _roundConfig = roundConfig; + _balanceConfig = balanceConfig; + } + + public float GetWinChance (int attackers, int defenders) + { + double winChance = 0; + double lossChance = 0; + if (attackers < _roundConfig.AttackDiceCount || defenders < _roundConfig.DefendDiceCount) + { + BattleConfig battleConfig = new BattleConfig(attackers, defenders, 0); + winChance = FastBattleEndCache.Get(_roundConfig, battleConfig).WinChance; + } + else + { + MultiRoundCacheInfo multiRoundCacheInfo = MultiRoundCache.Get(_roundConfig); + int attackerLossTarget = attackers - _roundConfig.AttackDiceCount + 1; + int defenderLossTarget = defenders - _roundConfig.DefendDiceCount + 1; + + // Go through every case where the defenders fall below their + // maximum dice roll before or at the same time as the attackers + // fall below their maximum dice roll. + List attackerLosses = multiRoundCacheInfo.GetFixedDefenderLoss(attackerLossTarget, defenderLossTarget); + for (int i = 0; i < attackerLosses.Count; i++) + { + int remainingDefenders = _roundConfig.DefendDiceCount - 1 - i; + double[] outcomeChances = attackerLosses[i].OutcomeChances; + if (remainingDefenders == 0) + { + for (int j = 0; j < outcomeChances.Length; j++) + { + winChance += outcomeChances[j]; + } + continue; + } + int remainingAttackers = attackers - attackerLosses[i].InitialLoss; + int challengeCount = _roundConfig.ChallengeCount; + int k = 0; + BattleConfig firstBattleConfig = new BattleConfig(remainingAttackers, remainingDefenders, 0); + FastBattleEndInfo firstEndInfo = FastBattleEndCache.Get(_roundConfig, firstBattleConfig); + for (; k < outcomeChances.Length && remainingAttackers > firstEndInfo.BattleConfig.AttackUnitCount; k++, remainingAttackers -= challengeCount) + { + winChance += outcomeChances[k]; + } + for (; k < outcomeChances.Length; k++, remainingAttackers -= challengeCount) + { + double outcomeChance = outcomeChances[k]; + BattleConfig battleConfig = new BattleConfig(remainingAttackers, remainingDefenders, 0); + FastBattleEndInfo endInfo = FastBattleEndCache.Get(_roundConfig, battleConfig); + winChance += outcomeChance * endInfo.WinChance; + lossChance += outcomeChance * (1.0 - endInfo.WinChance); + } + } + + // Go through every case where the attackers fall below their + // maximum dice roll strictly before the attackers fall below + // maximum dice roll. GetFixedAttackerLoss does include cases + // where both fall below the limit at the same time so we do + // explicitly filter these out here. + List defenderLosses = multiRoundCacheInfo.GetFixedAttackerLoss(attackerLossTarget, defenderLossTarget); + for (int i = 0; i < defenderLosses.Count; i++) + { + int remainingAttackers = _roundConfig.AttackDiceCount - 1 - i; + double[] outcomeChances = defenderLosses[i].OutcomeChances; + if (remainingAttackers == 0) + { + for (int j = 0; j < outcomeChances.Length; j++) + { + lossChance += outcomeChances[j]; + } + continue; + } + int defenderLoss = defenderLosses[i].InitialLoss; + int challengeCount = _roundConfig.ChallengeCount; + int k = 0; + for (; k < outcomeChances.Length; k++, defenderLoss -= challengeCount) + { + double outcomeChance = outcomeChances[k]; + if (outcomeChance <= 0.0 || defenderLoss >= defenderLossTarget) + { + continue; + } + BattleConfig battleConfig = new BattleConfig(remainingAttackers, defenders - defenderLoss, 0); + FastBattleEndInfo endInfo = FastBattleEndCache.Get(_roundConfig, battleConfig); + if (!endInfo.UseAllDefenders) + { + break; + } + winChance += outcomeChance * endInfo.WinChance; + lossChance += outcomeChance * (1.0 - endInfo.WinChance); + } + for (; k < outcomeChances.Length; k++, defenderLoss -= challengeCount) + { + lossChance += outcomeChances[k]; + } + } + + // If we used any gaussian approximation our odds might not + // quite sum to one, so we renormalize them here (except only + // winChance because we don't care about lossChance anymore). + winChance = winChance / (winChance + lossChance); + } + if (_balanceConfig != null) + { + return ApplyBalance((float)winChance); + } + else + { + return (float)winChance; + } + } + + private float ApplyBalance (float winChance) + { + winChance = ApplyWinChanceCutoff(winChance); + winChance = ApplyWinChancePower(winChance); + winChance = ApplyOutcomeCutoff(winChance); + + return winChance; + } + + private float ApplyWinChanceCutoff (float winChance) + { + if (_balanceConfig.WinChanceCutoff <= 0f) + { + return winChance; + } + else if (winChance < _balanceConfig.WinChanceCutoff) + { + return 0f; + } + else if (winChance > 1f - _balanceConfig.WinChanceCutoff) + { + return 1f; + } + + return winChance; + } + + private float ApplyWinChancePower (float winChance) + { + float a = Mathf.Pow(winChance, (float) _balanceConfig.WinChancePower); + float d = Mathf.Pow(1f - winChance, (float) _balanceConfig.WinChancePower); + + float ratio = 1f / (a + d); + + winChance = a * ratio; + + return winChance; + } + + private float ApplyOutcomeCutoff (float winChance) + { + float a = winChance - (float) _balanceConfig.OutcomeCutoff; + float d = (1f - winChance) - (float) _balanceConfig.OutcomeCutoff; + + if (a < 0) + { + return 0f; + } + + if (d < 0) + { + return 1f; + } + + float ratio = 1f / (a + d); + + winChance = a * ratio; + + return winChance; + } + } +}