using SPTarkov.Common.Extensions; using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Models.Utils; using SPTarkov.Server.Core.Utils.Cloners; namespace SPTarkov.Server.Core.Utils; // TODO: Finish porting this class [Injectable(InjectionType.Singleton)] public class RandomUtil(ISptLogger logger, ICloner cloner) { private const int DecimalPointRandomPrecision = 6; /// /// The IEEE-754 standard for double-precision floating-point numbers limits the number of digits (including both /// integer + fractional parts) to about 15–17 significant digits. 15 is a safe upper bound, so we'll use that. /// public const int MaxSignificantDigits = 15; private static readonly int _decimalPointRandomPrecisionMultiplier = (int)Math.Pow(10, DecimalPointRandomPrecision); public readonly Random Random = new(); /// /// Generates a random integer between the specified minimum and maximum values, inclusive. /// /// The minimum value (inclusive). /// The maximum value (optional). /// If max is exclusive or not. /// A random integer between the specified minimum and maximum values. public virtual int GetInt(int min, int max = int.MaxValue, bool exclusive = false) { // Prevents a potential integer overflow. if (exclusive && max == int.MaxValue) { max -= 1; } return max > min ? Random.Shared.Next(min, exclusive ? max : max + 1) : min; } /// /// Generates a random floating-point number within the specified range ~15-17 digits (8 bytes). /// /// The minimum value of the range (inclusive). /// The maximum value of the range (exclusive). /// A random floating-point number between `min` (inclusive) and `max` (exclusive). public virtual double GetDouble(double min, double max) { var realMin = (long)(min * _decimalPointRandomPrecisionMultiplier); var realMax = (long)(max * _decimalPointRandomPrecisionMultiplier); return Math.Round(Random.NextInt64(realMin, realMax) / (double)_decimalPointRandomPrecisionMultiplier, DecimalPointRandomPrecision); } /// /// Generates a random boolean value. /// /// A random boolean value, where the probability of `true` and `false` is approximately equal. public virtual bool GetBool() { return Random.Next(0, 2) == 1; } public virtual void NextBytes(Span bytes) { Random.Shared.NextBytes(bytes); } /// /// Calculates the percentage of a given number and returns the result. /// /// The percentage to calculate. /// The number to calculate the percentage of. /// The number of decimal places to round the result to (default is 2). /// The calculated percentage of the given number, rounded to the specified number of decimal places. public virtual double GetPercentOfValue(double percent, double number, int toFixed = 2) { var num = percent * (number / 100); return Math.Round(num, toFixed); } /// /// Calculates the percentage of a given number and returns the result. /// /// The percentage to calculate. /// The number to calculate the percentage of. /// The number of decimal places to round the result to (default is 2). /// The calculated percentage of the given number, rounded to the specified number of decimal places. public virtual float GetPercentOfValue(double percent, float number, int toFixed = 2) { var num = percent * (number / 100); return (float)Math.Round(num, toFixed); } /// /// Reduces a given number by a specified percentage. /// /// The original number to be reduced. /// The percentage by which to reduce the number. /// The reduced number after applying the percentage reduction. public virtual double ReduceValueByPercent(double number, double percentage) { var reductionAmount = number * percentage / 100; return number - reductionAmount; } /// /// Determines if a random event occurs based on the given chance percentage. /// /// The percentage chance (0-100) that the event will occur. /// `true` if the event occurs, `false` otherwise. public virtual bool GetChance100(double? chancePercent) { chancePercent = Math.Clamp(chancePercent ?? 0, 0D, 100D); return GetInt(1, 100) <= chancePercent; } /// /// Returns a random string from the provided collection of strings. /// This method is separate from GetCollectionValue so we can use a generic inference with GetCollectionValue. /// /// The collection of strings to select a random value from. /// A randomly selected string from the array. public virtual T GetRandomElement(IEnumerable collection) { // Already a List if (collection is IList list) { if (!list.Any()) { throw new InvalidOperationException("Sequence contains no elements."); } return list[GetInt(0, list.Count - 1)]; } // Faster than Reservoir Sampling or calling collection.Count() and doing above var toListedCollection = collection.ToList(); return toListedCollection[GetInt(0, toListedCollection.Count - 1)]; } /// /// Gets a random key from the given dictionary /// /// The dictionary from which to retrieve a key. /// Type of key /// Type of Value /// A random TKey representing one of the keys of the dictionary. public virtual TKey GetKey(Dictionary dictionary) where TKey : notnull { return GetRandomElement(dictionary.Keys); } /// /// Gets a random val from the given dictionary /// /// The dictionary from which to retrieve a value. /// Type of key /// Type of Value /// A random TVal representing one of the values of the dictionary. public virtual TVal GetVal(Dictionary dictionary) where TKey : notnull { return GetRandomElement(dictionary.Values); } /// /// Generates a normally distributed random number using the Box-Muller transform. /// /// The mean (μ) of the normal distribution. /// The standard deviation (σ) of the normal distribution. /// The current attempt count to generate a valid number (default is 0). /// A normally distributed random number. /// /// This function uses the Box-Muller transform to generate a normally distributed random number. /// If the generated number is less than 0, it will recursively attempt to generate a valid number up to 100 times. /// If it fails to generate a valid number after 100 attempts, it will return a random float between 0.01 and twice the mean. /// public virtual double GetNormallyDistributedRandomNumber(double mean, double sigma, int attempt = 0) { double u, v; do { u = GetSecureRandomNumber(); } while (u == 0); do { v = GetSecureRandomNumber(); } while (v == 0); // Apply the Box-Muller transform var w = Math.Sqrt(-2.0 * Math.Log(u)) * Math.Cos(2.0 * Math.PI * v); var valueDrawn = mean + w * sigma; // Check if the generated value is valid if (valueDrawn < 0) { return attempt > 100 ? GetDouble(0.01D, mean * 2D) : GetNormallyDistributedRandomNumber(mean, sigma, attempt + 1); } return valueDrawn; } /// /// Generates a random integer between the specified range. /// /// The lower bound of the range (inclusive). /// The upper bound of the range (exclusive). If not provided, the range will be from 0 to `low`. /// A random integer within the specified range. public virtual int RandInt(int low, int? high = null) { // Return a random integer from 0 to low if high is not provided if (high is null) { return Random.Next(0, low); } // Return low directly when low and high are equal return low == high ? low : Random.Next(low, (int)high); } /// /// Generates a random number between two given values with optional precision. /// /// The first value to determine the range. /// The second value to determine the range. If not provided, 0 is used. /// /// The number of decimal places to round the result to. Must be a positive integer between 0 /// and MaxSignificantDigits(15), inclusive. If not provided, precision is determined by the input values. /// /// public virtual double RandNum(double val1, double val2 = 0, int precision = DecimalPointRandomPrecision) { if (!double.IsFinite(val1) || !double.IsFinite(val2)) { throw new ArgumentException("RandNum() parameters 'value1' and 'value2' must be finite numbers."); } // Determine the range var min = Math.Min(val1, val2); var max = Math.Max(val1, val2); var realPrecision = (long)Math.Pow(10, precision); var minInt = (long)(min * realPrecision); var maxInt = (long)(max * realPrecision); return Math.Round(Random.NextInt64(minInt, maxInt) / (double)realPrecision, precision); } /// /// Draws a specified number of random elements from a given list. /// /// The list to draw elements from. /// The number of elements to draw. Defaults to 1. /// Whether to draw with replacement. Defaults to true. /// The type of elements in the list. /// A List containing the drawn elements. public virtual List DrawRandomFromList(List originalList, int count = 1, bool replacement = true) { var list = originalList; var drawCount = count; if (!replacement) { list = cloner.Clone(originalList); // Adjust drawCount to avoid drawing more elements than available if (drawCount > list.Count) { drawCount = list.Count; } } var results = new List(); for (var i = 0; i < drawCount; i++) { var randomIndex = RandInt(list.Count); if (replacement) { results.Add(list[randomIndex]); } else { results.Add(list.Splice(randomIndex, 1)[0]); } } return results; } /// /// Draws a specified number of random keys from a given dictionary. /// /// The dictionary from which to draw keys. /// The number of keys to draw. Defaults to 1. /// Whether to draw with replacement. Defaults to true. /// The type of elements in keys /// The type of elements in values /// A list of randomly drawn keys from the dictionary. public virtual List DrawRandomFromDict(Dictionary dict, int count = 1, bool replacement = true) where TKey : notnull { var keys = dict.Keys.ToList(); var randomKeys = DrawRandomFromList(keys, count, replacement); return randomKeys; } /// /// Generates a biased random number within a specified range. /// /// The minimum value of the range (inclusive). /// The maximum value of the range (inclusive). /// The bias shift to apply to the random number generation. /// The number of iterations to use for generating a Gaussian random number. /// A biased random number within the specified range. public virtual double GetBiasedRandomNumber(double min, double max, double shift, double n) { // This function generates a random number based on a gaussian distribution with an option to add a bias via shifting. // Here's an example graph of how the probabilities can be distributed: // https://www.boost.org/doc/libs/1_49_0/libs/math/doc/sf_and_dist/graphs/normal_pdf.png // Our parameter 'n' is sort of like σ (sigma) in the example graph. // An 'n' of 1 means all values are equally likely. Increasing 'n' causes numbers near the edge to become less likely. // By setting 'shift' to whatever 'max' is, we can make values near 'min' very likely, while values near 'max' become extremely unlikely. // Here's a place where you can play around with the 'n' and 'shift' values to see how the distribution changes: // http://jsfiddle.net/e08cumyx/ if (max < min) { logger.Error($"Invalid argument, Bounded random number generation max is smaller than min({max} < {min}"); return -1; } if (n < 1) { logger.Error($"Invalid argument, 'n' must be 1 or greater(received {n})"); return -1; } if (min == max) { return min; } if (shift > max - min) { // If a rolled number is out of bounds (due to bias being applied), we roll it again. // As the shifting increases, the chance of rolling a number within bounds decreases. // A shift that is equal to the available range only has a 50% chance of rolling correctly, theoretically halving performance. // Shifting even further drops the success chance very rapidly - so we want to warn against that logger.Warning( "Bias shift for random number generation is greater than the range of available numbers. This will have a severe performance impact" ); logger.Warning($"min-> {min}; max-> {max}; shift-> {shift}"); } var biasedMin = shift >= 0 ? min - shift : min; var biasedMax = shift < 0 ? max + shift : max; double num; do { num = GetBoundedGaussian(biasedMin, biasedMax, n); } while (num < min || num > max); return num; } protected double GetBoundedGaussian(double start, double end, double n) { return Math.Round(start + GetGaussianRandom(n) * (end - start + 1)); } protected double GetGaussianRandom(double n) { var rand = 0d; for (var i = 0; i < n; i += 1) { rand += GetSecureRandomNumber(); } return rand / n; } /// /// Shuffles a list in place using the Fisher-Yates algorithm. /// /// The list to shuffle. /// The type of elements in the list. /// The shuffled list. public virtual List Shuffle(List originalList) { var currentIndex = originalList.Count; while (currentIndex != 0) { var randomIndex = GetInt(0, currentIndex, true); currentIndex--; // Swap it with the current element. (originalList[currentIndex], originalList[randomIndex]) = (originalList[randomIndex], originalList[currentIndex]); } return originalList; } /// /// Generates a secure random number between 0 (inclusive) and 1 (exclusive). /// This method uses the `crypto` module to generate a 48-bit random integer, /// which is then divided by the maximum possible 48-bit integer value to /// produce a floating-point number in the range [0, 1). /// /// A secure random number between 0 (inclusive) and 1 (exclusive). private double GetSecureRandomNumber() { return Random.NextSingle(); } /// /// Determines the number of decimal places in a number. /// /// The number to analyze. /// The number of decimal places, or 0 if none exist. public virtual int GetNumberPrecision(double num) { var preciseNum = (decimal)num; var factor = 0; while ((double)(preciseNum % 1) > double.Epsilon) { preciseNum *= 10M; factor++; } return factor; } public virtual T? GetArrayValue(IEnumerable list) { return GetRandomElement(list); } /// /// Chance to roll a number out of 100 /// /// Percentage chance roll should success /// scale of chance to allow support of numbers > 1-100 /// true if success public virtual bool RollChance(double chance, double scale = 1) { return GetInt(1, (int)(100 * scale)) / (1 * scale) <= chance; } }