using System.Security.Cryptography; using SptCommon.Annotations; using Core.Models.Utils; using Core.Utils.Cloners; using SptCommon.Extensions; namespace Core.Utils; // TODO: Finish porting this class [Injectable(InjectionType.Singleton)] public class RandomUtil(ISptLogger _logger, ICloner _cloner) { public readonly Random Random = new(); /// /// 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; /// /// Generates a random integer between the specified minimum and maximum values, inclusive. /// /// The minimum value (inclusive). /// The maximum value (inclusive). /// A random integer between the specified minimum and maximum values. public int GetInt(int min, int max) { // Prevents a potential integer overflow. if (max == int.MaxValue) max -= 1; // maxVal is exclusive of the passed value, so add 1 return max > min ? Random.Next(min, max + 1) : min; } /// /// Generates a random integer between 1 (inclusive) and the specified maximum value (exclusive). /// If the maximum value is less than or equal to 1, it returns 1. /// /// The upper bound (exclusive) for the random integer generation. /// A random integer between 1 and max - 1, or 1 if max is less than or equal to 1. public int GetIntEx(int max) { return max > 2 ? Random.Next(1, max - 1) : 1; } /// /// Generates a random floating-point number within the specified range ~6-9 digits (4 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 float GetFloat(float min, float max) { return (float)GetSecureRandomNumber() * (max - min) + 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 double GetDouble(double min, double max) { return GetSecureRandomNumber() * (max - min) + min; } /// /// Generates a random boolean value. /// /// A random boolean value, where the probability of `true` and `false` is approximately equal. public bool GetBool() { return GetSecureRandomNumber() < 0.5; } /// /// 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 float GetPercentOfValue(double percent, double 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 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 bool GetChance100(double? chancePercent) { chancePercent = Math.Clamp(chancePercent ?? 0, 0f, 100f); return GetIntEx(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 string GetStringCollectionValue(IEnumerable collection) { return collection.ElementAt(GetInt(0, collection.Count() - 1)); } /// /// Returns a random type T from the provided collection of type T. /// /// The collection to get the random element from /// The type of elements in the collection. /// A random element from the collection. /// This was formerly getArrayValue() in the node server public T GetCollectionValue(IEnumerable collection) { return collection.ElementAt(GetInt(0, collection.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 TKey GetKey(Dictionary dictionary) where TKey : notnull { return GetCollectionValue(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 TVal GetVal(Dictionary dictionary) where TKey : notnull { return GetCollectionValue(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 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.01f, mean * 2f) : 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 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 double RandNum(double val1, double val2 = 0, byte? precision = null) { 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); // Validate and adjust precision if (precision is not null) { if (precision > MaxSignificantDigits) throw new ArgumentOutOfRangeException( nameof(precision), "Must be less than 16"); // Calculate the number of whole-number digits in the maximum absolute value of the range var maxAbsoluteValue = Math.Max(Math.Abs(min), Math.Abs(max)); var wholeNumberDigits = (int)Math.Floor(Math.Log10(maxAbsoluteValue)) + 1; var maxAllowedPrecision = Math.Max(0, MaxSignificantDigits - wholeNumberDigits); if (precision > maxAllowedPrecision) throw new ArgumentException( $"RandNum() precision of {precision} exceeds the allowable precision ({maxAllowedPrecision}) for the given values." ); } var result = GetSecureRandomNumber() * (max - min) + min; // Determine effective precision var maxPrecision = Math.Max(GetNumberPrecision(val1), GetNumberPrecision(val2)); var effectivePrecision = precision ?? maxPrecision; var factor = Math.Pow(2, effectivePrecision); return Math.Round(result * factor) / factor; } /// /// 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 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 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 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; } private double GetBoundedGaussian(double start, double end, double n) { return Math.Round(start + GetGaussianRandom(n) * (end - start + 1)); } private 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 List Shuffle(List originalList) { var currentIndex = originalList.Count; while (currentIndex != 0) { var randomIndex = GetInt(0, currentIndex); 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 static double GetSecureRandomNumber() { var buffer = new byte[6]; // 48 bits using (var rng = RandomNumberGenerator.Create()) { rng.GetBytes(buffer); } // Convert byte array to unsigned long ulong value = 0; for (var i = 0; i < buffer.Length; i++) { value |= (ulong)buffer[i] << (8 * (buffer.Length - 1 - i)); } const ulong maxInteger = 281474976710656; // 2^48 return (double)value / maxInteger; } /// /// Determines the number of decimal places in a number. /// /// The number to analyze. /// The number of decimal places, or 0 if none exist. public int GetNumberPrecision(double num) { var parts = num.ToString($"G{MaxSignificantDigits}").Split('.'); return parts.Length > 1 ? parts[1].Length : 0; } public T? GetArrayValue(IEnumerable list) { var rand = new Random(); try { return list.ElementAt(rand.Next(0, list.Count())); } catch (Exception) { return default; } } }