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; + } + } +}