From c071702851355953b5848ac77e05cdc4efe1abce Mon Sep 17 00:00:00 2001 From: Refringe Date: Fri, 6 Dec 2024 11:25:44 -0500 Subject: [PATCH] Better Random (#972) This started as a small update. This pull request includes updates to the `RandomUtil` class to improve the security and accuracy of random number generation by utilizing the `node:crypto` module. Additionally, it enhances the documentation and refactors several methods for clarity and performance. Includes tests, both before and after the changes. The only *possible* breaking change is that now the `getKey`, `getKeyValue`, and `drawRandomFromDict` methods are typed with generics instead of `any`. I tried to keep them as *generic* as possible, but we should probably include this in a BE to ensure it doesn't break anything. No further changes are planned--Saved as a draft because it's late and I still want to review. --------- Co-authored-by: Chomp <27521899+chompDev@users.noreply.github.com> --- project/biome.jsonc | 14 +- project/src/utils/RandomUtil.ts | 246 ++++++--- project/tests/utils/RandomUtil.test.ts | 663 +++++++++++++++++++++++++ 3 files changed, 858 insertions(+), 65 deletions(-) create mode 100644 project/tests/utils/RandomUtil.test.ts diff --git a/project/biome.jsonc b/project/biome.jsonc index b7c3612b..5235e1fc 100644 --- a/project/biome.jsonc +++ b/project/biome.jsonc @@ -69,5 +69,17 @@ "formatter": { "trailingCommas": "none" } - } + }, + "overrides": [ + { + "include": ["tests/*"], + "linter": { + "rules": { + "suspicious": { + "noExplicitAny": "off" + } + } + } + } + ] } diff --git a/project/src/utils/RandomUtil.ts b/project/src/utils/RandomUtil.ts index 0019ab5e..86f6e06a 100644 --- a/project/src/utils/RandomUtil.ts +++ b/project/src/utils/RandomUtil.ts @@ -1,3 +1,4 @@ +import * as crypto from "node:crypto"; import { ILogger } from "@spt/models/spt/utils/ILogger"; import { MathUtil } from "@spt/utils/MathUtil"; import { ICloner } from "@spt/utils/cloners/ICloner"; @@ -194,33 +195,90 @@ export class RandomUtil { @inject("PrimaryLogger") protected logger: ILogger, ) {} + /** + * 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). + * + * @returns A secure random number between 0 (inclusive) and 1 (exclusive). + */ + private getSecureRandomNumber(): number { + const buffer = crypto.randomBytes(6); // 48 bits + const integer = buffer.readUIntBE(0, 6); + const maxInteger = 281474976710656; // 2^48 + return integer / maxInteger; + } + + /** + * Generates a random integer between the specified minimum and maximum values, inclusive. + * + * @param min - The minimum value (inclusive). + * @param max - The maximum value (inclusive). + * @returns A random integer between the specified minimum and maximum values. + */ public getInt(min: number, max: number): number { const minimum = Math.ceil(min); const maximum = Math.floor(max); - return maximum > minimum ? Math.floor(Math.random() * (maximum - minimum + 1) + minimum) : minimum; + if (maximum > minimum) { + // randomInt is exclusive of the max value, so add 1 + return crypto.randomInt(minimum, maximum + 1); + } + return minimum; } + /** + * 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. + * + * @param max - The upper bound (exclusive) for the random integer generation. + * @returns A random integer between 1 and max - 1, or 1 if max is less than or equal to 1. + */ public getIntEx(max: number): number { - return max > 1 ? Math.floor(Math.random() * (max - 2) + 1) : 1; + return max > 2 ? crypto.randomInt(1, max - 1) : 1; } + /** + * Generates a random floating-point number within the specified range. + * + * @param min - The minimum value of the range (inclusive). + * @param max - The maximum value of the range (exclusive). + * @returns A random floating-point number between `min` (inclusive) and `max` (exclusive). + */ public getFloat(min: number, max: number): number { - return Math.random() * (max - min) + min; + const random = this.getSecureRandomNumber(); + return random * (max - min) + min; } + /** + * Generates a random boolean value. + * + * @returns A random boolean value, where the probability of `true` and `false` is approximately equal. + */ public getBool(): boolean { - return Math.random() < 0.5; + const random = this.getSecureRandomNumber(); + return random < 0.5; } + /** + * Calculates the percentage of a given number and returns the result. + * + * @param percent - The percentage to calculate. + * @param number - The number to calculate the percentage of. + * @param toFixed - The number of decimal places to round the result to (default is 2). + * @returns The calculated percentage of the given number, rounded to the specified number of decimal places. + */ public getPercentOfValue(percent: number, number: number, toFixed = 2): number { return Number.parseFloat(((percent * number) / 100).toFixed(toFixed)); } /** - * Reduce a value by a percentage - * @param number Value to reduce - * @param percentage Percentage to reduce value by - * @returns Reduced value + * Reduces a given number by a specified percentage. + * + * @param number - The original number to be reduced. + * @param percentage - The percentage by which to reduce the number. + * @returns The reduced number after applying the percentage reduction. */ public reduceValueByPercent(number: number, percentage: number): number { const reductionAmount = number * (percentage / 100); @@ -228,46 +286,87 @@ export class RandomUtil { } /** - * Check if number passes a check out of 100 - * @param chancePercent value check needs to be above - * @returns true if value passes check + * Determines if a random event occurs based on the given chance percentage. + * + * @param chancePercent - The percentage chance (0-100) that the event will occur. + * @returns `true` if the event occurs, `false` otherwise. */ public getChance100(chancePercent: number): boolean { return this.getIntEx(100) <= chancePercent; } - // Its better to keep this method separated from getArrayValue so we can use generic inferance on getArrayValue + /** + * Returns a random string from the provided array of strings. + * + * This method is separate from getArrayValue so we can use a generic inferance with getArrayValue. + * + * @param arr - The array of strings to select a random value from. + * @returns A randomly selected string from the array. + */ public getStringArrayValue(arr: string[]): string { return arr[this.getInt(0, arr.length - 1)]; } + /** + * Returns a random element from the provided array. + * + * @template T - The type of elements in the array. + * @param arr - The array from which to select a random element. + * @returns A random element from the array. + */ public getArrayValue(arr: T[]): T { return arr[this.getInt(0, arr.length - 1)]; } + /** + * Retrieves a random key from the given object. + * + * @param node - The object from which to retrieve a key. + * @returns A string representing one of the keys of the node object. + * + * TODO: v3.11 - This method is not type-safe and should be refactored to use a more specific type: + * https://github.com/sp-tarkov/server/pull/972/commits/f2b8efe211d95f71aec0a4bc84f4542335433412 + */ + // biome-ignore lint/suspicious/noExplicitAny: Used to allow for a broad range of types. public getKey(node: any): string { return this.getArrayValue(Object.keys(node)); } + /** + * Retrieves the value associated with a key from the given node object. + * + * @param node - An object with string keys and any type of values. + * @returns The value associated with the key obtained from the node. + * + * TODO: v3.11 - This method is not type-safe and should be refactored to use a more specific type: + * https://github.com/sp-tarkov/server/pull/972/commits/f2b8efe211d95f71aec0a4bc84f4542335433412 + */ + // biome-ignore lint/suspicious/noExplicitAny: Used to allow for a broad range of types. public getKeyValue(node: { [x: string]: any }): any { return node[this.getKey(node)]; } /** - * Generate a normally distributed random number - * Uses the Box-Muller transform - * @param {number} mean Mean of the normal distribution - * @param {number} sigma Standard deviation of the normal distribution - * @returns {number} The value drawn + * Generates a normally distributed random number using the Box-Muller transform. + * + * @param mean - The mean (μ) of the normal distribution. + * @param sigma - The standard deviation (σ) of the normal distribution. + * @param attempt - The current attempt count to generate a valid number (default is 0). + * @returns A normally distributed random number. + * + * @remarks + * 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 getNormallyDistributedRandomNumber(mean: number, sigma: number, attempt = 0): number { let u = 0; let v = 0; while (u === 0) { - u = Math.random(); // Converting [0,1) to (0,1) + u = this.getSecureRandomNumber(); } while (v === 0) { - v = Math.random(); + v = this.getSecureRandomNumber(); } const w = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); const valueDrawn = mean + w * sigma; @@ -283,36 +382,42 @@ export class RandomUtil { } /** - * Draw Random integer low inclusive, high exclusive - * if high is not set we draw from 0 to low (exclusive) - * @param {integer} low Lower bound inclusive, when high is not set, this is high - * @param {integer} high Higher bound exclusive - * @returns {integer} The random integer in [low, high) + * Generates a random integer between the specified range. + * + * @param low - The lower bound of the range (inclusive). + * @param high - The upper bound of the range (exclusive). If not provided, the range will be from 0 to `low`. + * @returns A random integer within the specified range. */ public randInt(low: number, high?: number): number { - if (high) { - return low + Math.floor(Math.random() * (high - low)); + if (typeof high !== "undefined") { + return crypto.randomInt(low, high); } - - return Math.floor(Math.random() * low); + return crypto.randomInt(0, low); } /** - * Draw a random element of the provided list N times to return an array of N random elements - * Drawing can be with or without replacement - * @param {array} list The array we want to draw randomly from - * @param {integer} count The number of times we want to draw - * @param {boolean} replacement Draw with or without replacement from the input array(default true) - * @return {array} Array consisting of N random elements + * Draws a specified number of random elements from a given list. + * + * @template T - The type of elements in the list. + * @param originalList - The list to draw elements from. + * @param count - The number of elements to draw. Defaults to 1. + * @param replacement - Whether to draw with replacement. Defaults to true. + * @returns An array containing the drawn elements. */ public drawRandomFromList(originalList: Array, count = 1, replacement = true): Array { let list = originalList; + let drawCount = count; + if (!replacement) { list = this.cloner.clone(originalList); + // Adjust drawCount to avoid drawing more elements than available + if (drawCount > list.length) { + drawCount = list.length; + } } const results: T[] = []; - for (let i = 0; i < count; i++) { + for (let i = 0; i < drawCount; i++) { const randomIndex = this.randInt(list.length); if (replacement) { results.push(list[randomIndex]); @@ -324,33 +429,48 @@ export class RandomUtil { } /** - * Draw a random (top level) element of the provided dictionary N times to return an array of N random dictionary keys - * Drawing can be with or without replacement - * @param {any} dict The dictionary we want to draw randomly from - * @param {integer} count The number of times we want to draw - * @param {boolean} replacement Draw with ot without replacement from the input dict - * @return {array} Array consisting of N random keys of the dictionary + * Draws a specified number of random keys from a given dictionary. + * + * @param dict - The dictionary from which to draw keys. + * @param count - The number of keys to draw. Defaults to 1. + * @param replacement - Whether to draw with replacement. Defaults to true. + * @returns An array of randomly drawn keys from the dictionary. + * + * TODO: v3.11 - This method is not type-safe and should be refactored to use a more specific type: + * https://github.com/sp-tarkov/server/pull/972/commits/f2b8efe211d95f71aec0a4bc84f4542335433412 */ + // biome-ignore lint/suspicious/noExplicitAny: Used to allow for a broad range of types. public drawRandomFromDict(dict: any, count = 1, replacement = true): any[] { const keys = Object.keys(dict); const randomKeys = this.drawRandomFromList(keys, count, replacement); return randomKeys; } + /** + * Generates a biased random number within a specified range. + * + * @param min - The minimum value of the range (inclusive). + * @param max - The maximum value of the range (inclusive). + * @param shift - The bias shift to apply to the random number generation. + * @param n - The number of iterations to use for generating a Gaussian random number. + * @returns A biased random number within the specified range. + * @throws Will throw if `max` is less than `min` or if `n` is less than 1. + */ public getBiasedRandomNumber(min: number, max: number, shift: number, n: number): number { - /* To whoever tries to make sense of this, please forgive me - I tried my best at explaining what goes on here. + /** * 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/ */ - + * http://jsfiddle.net/e08cumyx/ + */ if (max < min) { throw { name: "Invalid arguments", @@ -367,13 +487,14 @@ export class RandomUtil { } if (shift > max - min) { - /* If a rolled number is out of bounds (due to bias being applied), we simply roll it again. + /** + * If a rolled number is out of bounds (due to bias being applied), we simply 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 */ - + * Shifting even further drops the success chance very rapidly - so we want to warn against that + **/ this.logger.warning( - "Bias shift for random number generation is greater than the range of available numbers.\nThis can have a very severe performance impact!", + "Bias shift for random number generation is greater than the range of available numbers. This can have a very severe performance impact!", ); this.logger.info(`min -> ${min}; max -> ${max}; shift -> ${shift}`); } @@ -382,7 +503,7 @@ export class RandomUtil { let rand = 0; for (let i = 0; i < n; i += 1) { - rand += Math.random(); + rand += this.getSecureRandomNumber(); } return rand / n; @@ -404,9 +525,11 @@ export class RandomUtil { } /** - * Fisher-Yates shuffle an array - * @param array Array to shuffle - * @returns Shuffled array + * Shuffles an array in place using the Fisher-Yates algorithm. + * + * @template T - The type of elements in the array. + * @param array - The array to shuffle. + * @returns The shuffled array. */ public shuffle(array: Array): Array { let currentIndex = array.length; @@ -415,7 +538,7 @@ export class RandomUtil { // While there remain elements to shuffle. while (currentIndex !== 0) { // Pick a remaining element. - randomIndex = Math.floor(Math.random() * currentIndex); + randomIndex = crypto.randomInt(0, currentIndex); currentIndex--; // And swap it with the current element. @@ -426,18 +549,13 @@ export class RandomUtil { } /** - * Rolls for a probability based on chance - * @param number Probability Chance as float (0-1) - * @returns If roll succeed or not - * @example - * rollForChanceProbability(0.25); // returns true 25% probability + * Rolls for a chance probability and returns whether the roll is successful. + * + * @param probabilityChance - The probability chance to roll for, represented as a number between 0 and 1. + * @returns `true` if the random number is less than or equal to the probability chance, otherwise `false`. */ public rollForChanceProbability(probabilityChance: number): boolean { - const maxRoll = 9999; - - // Roll a number between 0 and 1 - const rolledChance = this.getInt(0, maxRoll) / 10000; - - return rolledChance <= probabilityChance; + const random = this.getSecureRandomNumber(); + return random <= probabilityChance; } } diff --git a/project/tests/utils/RandomUtil.test.ts b/project/tests/utils/RandomUtil.test.ts new file mode 100644 index 00000000..e570df70 --- /dev/null +++ b/project/tests/utils/RandomUtil.test.ts @@ -0,0 +1,663 @@ +import "reflect-metadata"; +import { RandomUtil } from "@spt/utils/RandomUtil"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("RandomUtil", () => { + let randomUtil: RandomUtil; + let mockCloner: any; + let mockLogger: any; + + beforeEach(() => { + mockCloner = { + clone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))), + }; + mockLogger = { + warning: vi.fn(), + info: vi.fn(), + }; + randomUtil = new RandomUtil(mockCloner, mockLogger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("getInt", () => { + it("should return an integer between min and max inclusive when min < max", () => { + const min = 1; + const max = 5; + const result = randomUtil.getInt(min, max); + + expect(result).toBeGreaterThanOrEqual(Math.ceil(min)); + expect(result).toBeLessThanOrEqual(Math.floor(max)); + expect(Number.isInteger(result)).toBe(true); + }); + + it("should handle floating-point min and max values", () => { + const min = 1.2; + const max = 5.8; + const result = randomUtil.getInt(min, max); + + expect(result).toBeGreaterThanOrEqual(Math.ceil(min)); // 2 + expect(result).toBeLessThanOrEqual(Math.floor(max)); // 5 + expect(Number.isInteger(result)).toBe(true); + }); + + it("should return min when min and max are equal", () => { + const min = 3; + const max = 3; + const result = randomUtil.getInt(min, max); + + expect(result).toBe(Math.ceil(min)); + expect(Number.isInteger(result)).toBe(true); + }); + + it("should return min when max is less than min", () => { + const min = 5; + const max = 3; + const result = randomUtil.getInt(min, max); + + expect(result).toBe(Math.ceil(min)); + expect(Number.isInteger(result)).toBe(true); + }); + + it("should handle negative min and max values", () => { + const min = -5; + const max = -1; + const result = randomUtil.getInt(min, max); + + expect(result).toBeGreaterThanOrEqual(Math.ceil(min)); + expect(result).toBeLessThanOrEqual(Math.floor(max)); + expect(Number.isInteger(result)).toBe(true); + }); + }); + + describe("getIntEx", () => { + it("should return an integer between 1 and max - 2 inclusive when max > 1", () => { + const max = 10; + const result = randomUtil.getIntEx(max); + + expect(result).toBeGreaterThanOrEqual(1); + expect(result).toBeLessThanOrEqual(max - 2); + expect(Number.isInteger(result)).toBe(true); + }); + + it("should return 1 when max is less than or equal to 1", () => { + const maxValues = [1, 0, -5]; + for (const max of maxValues) { + const result = randomUtil.getIntEx(max); + expect(result).toBe(1); + } + }); + + it("should handle edge case when max is 2", () => { + const max = 2; + const result = randomUtil.getIntEx(max); + + expect(result).toBe(1); + }); + }); + + describe("getFloat", () => { + it("should return a float between min and max", () => { + const min = 1.5; + const max = 5.5; + const result = randomUtil.getFloat(min, max); + + expect(result).toBeGreaterThanOrEqual(min); + expect(result).toBeLessThan(max); + }); + + it("should handle negative min and max values", () => { + const min = -5.5; + const max = -1.1; + const result = randomUtil.getFloat(min, max); + + expect(result).toBeGreaterThanOrEqual(min); + expect(result).toBeLessThan(max); + }); + + it("should return min when min equals max", () => { + const min = 3.3; + const max = 3.3; + const result = randomUtil.getFloat(min, max); + + expect(result).toBe(min); + }); + }); + + describe("getBool", () => { + it("should return a boolean value", () => { + const result = randomUtil.getBool(); + expect(typeof result).toBe("boolean"); + }); + + it("should return true when Math.random is less than 0.5", () => { + vi.spyOn(randomUtil as any, "getSecureRandomNumber").mockReturnValue(0.4); + const result = randomUtil.getBool(); + expect(result).toBe(true); + }); + + it("should return false when getSecureRandomNumber returns 0.5", () => { + vi.spyOn(randomUtil as any, "getSecureRandomNumber").mockReturnValue(0.5); + const result = randomUtil.getBool(); + expect(result).toBe(false); + }); + }); + + describe("getPercentOfValue", () => { + it("should calculate the correct percentage of a number", () => { + const percent = 25; + const number = 200; + const result = randomUtil.getPercentOfValue(percent, number); + + expect(result).toBe(50.0); + }); + + it("should handle decimal percentages and numbers", () => { + const percent = 12.5; + const number = 80.4; + const result = randomUtil.getPercentOfValue(percent, number); + + expect(result).toBeCloseTo(10.05, 2); + }); + + it("should respect the toFixed parameter", () => { + const percent = 33.3333; + const number = 100; + const result = randomUtil.getPercentOfValue(percent, number, 4); + + expect(result).toBe(33.3333); + }); + }); + + describe("reduceValueByPercent", () => { + it("should reduce the value by the given percentage", () => { + const number = 200; + const percentage = 25; + const result = randomUtil.reduceValueByPercent(number, percentage); + + expect(result).toBe(150); + }); + + it("should handle decimal percentages", () => { + const number = 100; + const percentage = 12.5; + const result = randomUtil.reduceValueByPercent(number, percentage); + + expect(result).toBe(87.5); + }); + + it("should return the same number when percentage is 0", () => { + const number = 100; + const percentage = 0; + const result = randomUtil.reduceValueByPercent(number, percentage); + + expect(result).toBe(100); + }); + }); + + describe("getChance100", () => { + it("should return true if random number is less than or equal to chancePercent", () => { + vi.spyOn(randomUtil, "getIntEx").mockReturnValue(50); + + const chancePercent = 60; + const result = randomUtil.getChance100(chancePercent); + + expect(result).toBe(true); + expect(randomUtil.getIntEx).toHaveBeenCalledWith(100); + }); + + it("should return false if random number is greater than chancePercent", () => { + vi.spyOn(randomUtil, "getIntEx").mockReturnValue(70); + + const chancePercent = 60; + const result = randomUtil.getChance100(chancePercent); + + expect(result).toBe(false); + }); + }); + + describe("getStringArrayValue", () => { + it("should return a value from the array", () => { + const arr = ["apple", "banana", "cherry"]; + const result = randomUtil.getStringArrayValue(arr); + + expect(arr).toContain(result); + }); + + it("should handle single-element arrays", () => { + const arr = ["only"]; + const result = randomUtil.getStringArrayValue(arr); + + expect(result).toBe("only"); + }); + + it("should return predictable value when getInt is mocked", () => { + vi.spyOn(randomUtil, "getInt").mockReturnValue(1); + + const arr = ["first", "second", "third"]; + const result = randomUtil.getStringArrayValue(arr); + + expect(result).toBe("second"); + }); + }); + + describe("getArrayValue", () => { + it("should return a value from the array of numbers", () => { + const arr = [10, 20, 30, 40]; + const result = randomUtil.getArrayValue(arr); + + expect(arr).toContain(result); + }); + + it("should return a value from the array of objects", () => { + const arr = [{ id: 1 }, { id: 2 }, { id: 3 }]; + const result = randomUtil.getArrayValue(arr); + + expect(arr).toContain(result); + }); + + it("should return predictable value when getInt is mocked", () => { + vi.spyOn(randomUtil, "getInt").mockReturnValue(2); + + const arr = ["a", "b", "c", "d"]; + const result = randomUtil.getArrayValue(arr); + + expect(result).toBe("c"); + }); + }); + + describe("getKey", () => { + it("should return a key from the object", () => { + const obj = { a: 1, b: 2, c: 3 }; + const result = randomUtil.getKey(obj); + + expect(Object.keys(obj)).toContain(result); + }); + + it("should handle single-key objects", () => { + const obj = { onlyKey: "value" }; + const result = randomUtil.getKey(obj); + + expect(result).toBe("onlyKey"); + }); + + it("should handle empty objects", () => { + const obj = {}; + const result = randomUtil.getKey(obj); + + expect(result).toBeUndefined(); + }); + + it("should handle objects with integer keys", () => { + const obj = { 1: "a", 2: "b", 3: "c" }; + const result = randomUtil.getKey(obj); + + expect(Object.keys(obj)).toContain(result); + }); + + it("should return predictable key when getArrayValue is mocked", () => { + vi.spyOn(randomUtil, "getArrayValue").mockReturnValue("b"); + + const obj = { a: 1, b: 2, c: 3 }; + const result = randomUtil.getKey(obj); + + expect(result).toBe("b"); + }); + }); + + describe("getKeyValue", () => { + it("should return a value from the object", () => { + vi.spyOn(randomUtil, "getKey").mockReturnValue("b"); + + const obj = { a: 1, b: 2, c: 3 }; + const result = randomUtil.getKeyValue(obj); + + expect(result).toBe(2); + }); + + it("should handle objects with complex values", () => { + vi.spyOn(randomUtil, "getKey").mockReturnValue("key2"); + + const obj = { key1: "value1", key2: { nested: true }, key3: [1, 2, 3] }; + const result = randomUtil.getKeyValue(obj); + + expect(result).toEqual({ nested: true }); + }); + }); + + describe("getNormallyDistributedRandomNumber", () => { + it("should return a number close to the mean", () => { + const mean = 50; + const sigma = 5; + const result = randomUtil.getNormallyDistributedRandomNumber(mean, sigma); + + expect(result).toBeGreaterThanOrEqual(0); + expect(typeof result).toBe("number"); + }); + + it("should not return negative numbers", () => { + const mean = 5; + const sigma = 10; + const result = randomUtil.getNormallyDistributedRandomNumber(mean, sigma); + + expect(result).toBeGreaterThanOrEqual(0); + }); + + it("should handle high attempt counts", () => { + let callCount = 0; + vi.spyOn(randomUtil as any, "getSecureRandomNumber").mockImplementation(() => { + callCount++; + // Alternate between u and v + if (callCount % 2 === 1) { + // u values + return 0.00001; + } + // v values + return 0.5; + }); + + const mean = 5; + const sigma = 2; + const result = randomUtil.getNormallyDistributedRandomNumber(mean, sigma); + + // Each attempt increases callCount by 2 (once for u, once for v) + const attempts = callCount / 2; + + // Ensure that the method attempted multiple times + expect(attempts).toBeGreaterThan(100); + + // The result should be from the fallback value + expect(result).toBeGreaterThanOrEqual(0.01); + expect(result).toBeLessThanOrEqual(mean * 2); + }); + + it("should return a fallback value after many attempts", () => { + let callCount = 0; + vi.spyOn(randomUtil as any, "getSecureRandomNumber").mockImplementation(() => { + // Alternate between u and v + const value = callCount % 2 === 0 ? 0.0000001 : 0.5; + callCount++; + return value; + }); + + // Mock getFloat to return a predictable value + vi.spyOn(randomUtil, "getFloat").mockReturnValue(7.77); + + const mean = 5; + const sigma = 2; + const result = randomUtil.getNormallyDistributedRandomNumber(mean, sigma, 101); + + expect(result).toBe(7.77); + }); + }); + + describe("randInt", () => { + it("should return an integer between low and high - 1", () => { + const low = 5; + const high = 10; + const result = randomUtil.randInt(low, high); + + expect(result).toBeGreaterThanOrEqual(low); + expect(result).toBeLessThan(high); + expect(Number.isInteger(result)).toBe(true); + }); + + it("should return an integer between 0 and low - 1 when high is not provided", () => { + const low = 5; + const result = randomUtil.randInt(low); + + expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBeLessThan(low); + expect(Number.isInteger(result)).toBe(true); + }); + + it("should handle negative values", () => { + const low = -10; + const high = -5; + const result = randomUtil.randInt(low, high); + + expect(result).toBeGreaterThanOrEqual(low); + expect(result).toBeLessThan(high); + }); + }); + + describe("drawRandomFromList", () => { + it("should draw elements with replacement", () => { + const list = [1, 2, 3, 4, 5]; + const count = 3; + const result = randomUtil.drawRandomFromList(list, count, true); + + expect(result.length).toBe(count); + for (const item of result) { + expect(list).toContain(item); + } + }); + + it("should draw elements without replacement", () => { + mockCloner.clone.mockImplementation((obj) => [...obj]); + + const list = [1, 2, 3, 4, 5]; + const count = 3; + const result = randomUtil.drawRandomFromList(list, count, false); + + expect(result.length).toBe(count); + expect(new Set(result).size).toBe(count); + }); + + it("should clone the original list when drawing without replacement", () => { + const list = [1, 2, 3]; + randomUtil.drawRandomFromList(list, 2, false); + + expect(mockCloner.clone).toHaveBeenCalledWith(list); + }); + + it("should handle count greater than list length without replacement", () => { + const list = [1, 2, 3]; + const count = 5; + const result = randomUtil.drawRandomFromList(list, count, false); + + expect(result.length).toBe(3); + }); + + it("should adjust count when count exceeds list length without replacement", () => { + const list = [10, 20, 30]; + const count = 4; // Count exceeds list length + const result = randomUtil.drawRandomFromList(list, count, false); + + // The result should contain all elements from the list without duplicates + expect(result.length).toBe(3); // Should be adjusted to list length + expect(new Set(result).size).toBe(3); + + // Ensure that the result contains all elements from the original list + expect(result.sort()).toEqual(list.sort()); + }); + }); + + describe("drawRandomFromDict", () => { + it("should draw keys from the dictionary with replacement", () => { + const dict = { a: 1, b: 2, c: 3 }; + const count = 2; + const result = randomUtil.drawRandomFromDict(dict, count, true); + + expect(result.length).toBe(count); + for (const key of result) { + expect(Object.keys(dict)).toContain(key); + } + }); + + it("should draw keys without replacement", () => { + const dict = { a: 1, b: 2, c: 3 }; + const count = 2; + const result = randomUtil.drawRandomFromDict(dict, count, false); + + expect(result.length).toBe(count); + expect(new Set(result).size).toBe(count); + }); + + it("should handle single-key dictionaries", () => { + const dict = { onlyKey: 1 }; + const count = 2; + const result = randomUtil.drawRandomFromDict(dict, count, false); + + expect(result.length).toBe(1); + expect(result).toEqual(["onlyKey"]); + }); + + it("should handle dictionaries with integer keys", () => { + const dict = { 1: "a", 2: "b", 3: "c" }; + const count = 2; + const result = randomUtil.drawRandomFromDict(dict, count, false); + + expect(result.length).toBe(count); + for (const key of result) { + expect(Object.keys(dict)).toContain(key); + } + }); + + it("should handle count greater than number of keys without replacement", () => { + const dict = { a: 1, b: 2 }; + const count = 3; + const result = randomUtil.drawRandomFromDict(dict, count, false); + + expect(result.length).toBe(2); + }); + + it("should adjust count when count exceeds number of keys without replacement", () => { + const dict = { a: 1, b: 2, c: 3 }; + const count = 5; // Count exceeds number of keys + const result = randomUtil.drawRandomFromDict(dict, count, false); + + // The result should contain all keys without duplicates + expect(result.length).toBe(3); // Should be adjusted to number of keys + expect(new Set(result).size).toBe(3); + + // Ensure that the result contains all keys from the dictionary + const dictKeys = Object.keys(dict); + expect(result.sort()).toEqual(dictKeys.sort()); + }); + }); + + describe("getBiasedRandomNumber", () => { + it("should return a number within the range", () => { + const min = 1; + const max = 10; + const shift = 2; + const n = 2; + const result = randomUtil.getBiasedRandomNumber(min, max, shift, n); + + expect(result).toBeGreaterThanOrEqual(min); + expect(result).toBeLessThanOrEqual(max); + }); + + it("should throw error when max < min", () => { + expect(() => randomUtil.getBiasedRandomNumber(10, 5, 0, 2)).toThrowError( + "Bounded random number generation max is smaller than min (5 < 10)", + ); + }); + + it("should throw error when n < 1", () => { + expect(() => randomUtil.getBiasedRandomNumber(1, 10, 0, 0)).toThrowError( + "'n' must be 1 or greater (received 0)", + ); + }); + + it("should log warning when shift is greater than range", () => { + const min = 1; + const max = 5; + const shift = 10; + const n = 2; + randomUtil.getBiasedRandomNumber(min, max, shift, n); + + expect(mockLogger.warning).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith(`min -> ${min}; max -> ${max}; shift -> ${shift}`); + }); + + it("should return predictable result when getSecureRandomNumber is mocked", () => { + vi.spyOn(randomUtil as any, "getSecureRandomNumber").mockReturnValue(0.5); + + const min = 1; + const max = 10; + const shift = 0; + const n = 2; // n affects how many times getSecureRandomNumber is summed/averaged + + // With getSecureRandomNumber always returning 0.5, + // gaussianRandom(n) = 0.5 no matter what + // boundedGaussian(start, end, n) = round(start + 0.5 * (end - start + 1)) + + // For shift = 0: + // biasedMin = min = 1 + // biasedMax = max = 10 + // boundedGaussian(1, 10, 2) = round(1 + 0.5*(10 - 1 + 1)) = round(1 + 0.5*10) = round(1+5) = 6 + + // The loop ensures num is within [min, max], and since 6 is within [1,10], it returns 6 immediately. + const result = randomUtil.getBiasedRandomNumber(min, max, shift, n); + + expect(result).toBe(6); + }); + }); + + describe("shuffle", () => { + it("should shuffle the array", () => { + const array = [1, 2, 3, 4, 5]; + const shuffled = randomUtil.shuffle([...array]); + + expect(shuffled).toHaveLength(array.length); + expect(shuffled).not.toEqual(array); + expect(shuffled.sort()).toEqual(array.sort()); + }); + + it("should handle empty arrays", () => { + const array: any[] = []; + const shuffled = randomUtil.shuffle(array); + + expect(shuffled).toEqual([]); + }); + + it("should return the same array when array length is 1", () => { + const array = [1]; + const shuffled = randomUtil.shuffle(array); + + expect(shuffled).toEqual([1]); + }); + }); + + describe("rollForChanceProbability", () => { + it("should return true when rolled chance is less than or equal to probabilityChance", () => { + vi.spyOn(randomUtil as any, "getSecureRandomNumber").mockReturnValue(0.5); + + const probabilityChance = 0.6; + const result = randomUtil.rollForChanceProbability(probabilityChance); + + expect(result).toBe(true); + }); + + it("should return false when rolled chance is greater than probabilityChance", () => { + vi.spyOn(randomUtil as any, "getSecureRandomNumber").mockReturnValue(0.7); + + const probabilityChance = 0.6; + const result = randomUtil.rollForChanceProbability(probabilityChance); + + expect(result).toBe(false); + }); + + it("should handle probabilityChance of 0", () => { + vi.spyOn(randomUtil as any, "getSecureRandomNumber").mockReturnValue(0.1); + + const probabilityChance = 0; + const result = randomUtil.rollForChanceProbability(probabilityChance); + + expect(result).toBe(false); + }); + + it("should handle probabilityChance of 1", () => { + vi.spyOn(randomUtil as any, "getSecureRandomNumber").mockReturnValue(0.99); + + const probabilityChance = 1; + const result = randomUtil.rollForChanceProbability(probabilityChance); + + expect(result).toBe(true); + }); + }); +});