0
0
mirror of https://github.com/sp-tarkov/server.git synced 2025-02-12 16:30:42 -05:00

RandomUtil Changes (#980)

This pull request introduces several enhancements and fixes to the
`RandomUtil` class in the `project/src/utils/RandomUtil.ts` file, as
well as corresponding updates to the test suite in
`project/tests/utils/RandomUtil.test.ts`. The primary changes include
the addition of new methods for determining number precision and
generating random numbers with specified precision, as well as
improvements to existing methods to handle edge cases and log warnings
appropriately.

Enhancements to `RandomUtil` class:

* Added `MAX_SIGNIFICANT_DIGITS` constant to define the safe upper bound
for significant digits in floating-point numbers.
* Introduced `getNumberPrecision` method to determine the number of
decimal places in a number, addressing floating-point precision issues.
* Enhanced `randInt` method to handle float inputs by logging a warning
and rounding to the nearest integer.
* Added `randNum` method to generate random numbers between two values
with optional precision, including validation for precision and handling
of edge cases.

Updates to test suite:

* Added tests for the new `getNumberPrecision` method to verify correct
handling of various numeric inputs.
* Expanded tests for `randInt` to cover scenarios with equal low and
high values, float inputs, and logging of debug messages.
* Added comprehensive tests for the new `randNum` method to ensure
correct functionality across different ranges, precision levels, and
edge cases.
This commit is contained in:
Chomp 2024-12-10 09:53:01 +00:00 committed by GitHub
commit 9c1f692746
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 239 additions and 6 deletions

View File

@ -195,6 +195,12 @@ export class RandomUtil {
@inject("PrimaryLogger") protected logger: ILogger,
) {}
/**
* The IEEE-754 standard for double-precision floating-point numbers limits the number of digits (including both
* integer + fractional parts) to about 1517 significant digits. 15 is a safe upper bound, so we'll use that.
*/
private static readonly MAX_SIGNIFICANT_DIGITS = 15;
/**
* Generates a secure random number between 0 (inclusive) and 1 (exclusive).
*
@ -211,6 +217,19 @@ export class RandomUtil {
return integer / maxInteger;
}
/**
* Determines the number of decimal places in a number.
*
* @param num - The number to analyze.
* @returns The number of decimal places, or 0 if none exist.
* @remarks There is a mathematical way to determine this, but it's not as simple as it seams due to floating point
* precision issues. This method is a simple workaround that converts the number to a string and splits it.
* It's not the most efficient but it *is* the most reliable and easy to understand. Come at me.
*/
private getNumberPrecision(num: number): number {
return num.toString().split(".")[1]?.length || 0;
}
/**
* Generates a random integer between the specified minimum and maximum values, inclusive.
*
@ -298,7 +317,7 @@ export class RandomUtil {
/**
* 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.
* This method is separate from getArrayValue so we can use a generic inference with getArrayValue.
*
* @param arr - The array of strings to select a random value from.
* @returns A randomly selected string from the array.
@ -383,20 +402,97 @@ export class RandomUtil {
/**
* Generates a random integer between the specified range.
* Low and high parameters are floored to integers.
*
* TODO: v3.11 - This method should not accept non-integer numbers.
*
* @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 (low === high) {
return low;
let randomLow = low;
let randomHigh = high;
// Detect if either of the parameters is a float, and log a warning
if (low % 1 !== 0 || (typeof high !== "undefined" && high % 1 !== 0)) {
this.logger.debug(
"Deprecated: RandomUtil.randInt() called with float input. Use RandomUtil.randNum() instead.",
);
// Round the float values to the nearest integer. Eww!
randomLow = Math.floor(low);
if (typeof high !== "undefined") {
randomHigh = Math.floor(high);
}
}
if (typeof high !== "undefined") {
return crypto.randomInt(low, high);
// Return a random integer from 0 to low if high is not provided
if (typeof high === "undefined") {
return crypto.randomInt(0, randomLow);
}
return crypto.randomInt(0, low);
// Return low directly when low and high are equal
if (low === high) {
return randomLow;
}
return crypto.randomInt(randomLow, randomHigh);
}
/**
* Generates a random number between two given values with optional precision.
*
* @param value1 - The first value to determine the range.
* @param value2 - The second value to determine the range. If not provided, 0 is used.
* @param precision - The number of decimal places to round the result to. Must be a positive integer between 0
* and MAX_PRECISION, inclusive. If not provided, precision is determined by the input values.
* @returns A random floating-point number between `value1` and `value2` (inclusive) with the specified precision.
* @throws Will throw an error if `precision` is not a positive integer, if `value1` or `value2` are not finite
* numbers, or if the precision exceeds the maximum allowed for the given values.
*/
public randNum(value1: number, value2 = 0, precision: number | null = null): number {
if (!Number.isFinite(value1) || !Number.isFinite(value2)) {
throw new Error("randNum() parameters 'value1' and 'value2' must be finite numbers");
}
// Determine the range by finding the min and max of the provided values
const min = Math.min(value1, value2);
const max = Math.max(value1, value2);
// Validate and adjust precision
if (precision !== null) {
if (!Number.isInteger(precision) || precision < 0) {
throw new Error(`randNum() parameter 'precision' must be a positive integer`);
}
// Calculate the number of whole-number digits in the maximum absolute value of the range
const maxAbsoluteValue = Math.max(Math.abs(min), Math.abs(max));
const wholeNumberDigits = Math.floor(Math.log10(maxAbsoluteValue)) + 1;
// Determine the maximum allowable precision--The number of decimal places that can be used without losing
// precision due to the number of bits available in a double-precision floating-point number
const maxAllowedPrecision = Math.max(0, RandomUtil.MAX_SIGNIFICANT_DIGITS - wholeNumberDigits);
// Throw if the requested precision exceeds the maximum
if (precision > maxAllowedPrecision) {
throw new Error(
`randNum() precision of ${precision} exceeds the allowable precision (${maxAllowedPrecision}) for the given values`,
);
}
}
// Generate a random number within a specified range
const random = this.getSecureRandomNumber();
const result = random * (max - min) + min;
// Determine the maximum precision to use for rounding the result
const maxPrecision = Math.max(this.getNumberPrecision(value1), this.getNumberPrecision(value2));
const effectivePrecision = precision ?? maxPrecision;
// Calculate the factor to use for rounding the result to the specified precision
const factor = 10 ** effectivePrecision;
return Math.round(result * factor) / factor;
}
/**

View File

@ -14,6 +14,7 @@ describe("RandomUtil", () => {
mockLogger = {
warning: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
};
randomUtil = new RandomUtil(mockCloner, mockLogger);
});
@ -22,6 +23,29 @@ describe("RandomUtil", () => {
vi.restoreAllMocks();
});
describe("getNumberPrecision", () => {
it("should return the number of decimal places in a single digit number with up to 15 decimal places (IEEE-754 standard safe-upper bounds)", () => {
const number = 0.123456789012345;
const result = (randomUtil as any).getNumberPrecision(number);
expect(result).toBe(15);
});
it("should return 0 for whole numbers", () => {
const number = 123;
const result = (randomUtil as any).getNumberPrecision(number);
expect(result).toBe(0);
});
it("should return 0 for numbers with zero value decimals", () => {
const number = 123.0;
const result = (randomUtil as any).getNumberPrecision(number);
expect(result).toBe(0);
});
});
describe("getInt", () => {
it("should return an integer between min and max inclusive when min < max", () => {
const min = 1;
@ -394,6 +418,22 @@ describe("RandomUtil", () => {
});
describe("randInt", () => {
it("should return the same value when low and high are equal", () => {
const result = randomUtil.randInt(5, 5);
expect(result).toBe(5);
});
it("should work with float number parameters", () => {
const result = randomUtil.randInt(5.5, 10.5);
expect(result).toBeGreaterThanOrEqual(5);
expect(result).toBeLessThan(11);
});
it("should log a debug message with float number parameters", () => {
randomUtil.randInt(5.5, 10.5);
expect(mockLogger.debug).toHaveBeenCalled();
});
it("should return an integer between low and high - 1", () => {
const low = 5;
const high = 10;
@ -423,6 +463,103 @@ describe("RandomUtil", () => {
});
});
describe("randNum", () => {
it("should return the same value when low and high are equal", () => {
const resultWithPrecision = randomUtil.randNum(5.555, 5.555);
expect(resultWithPrecision).toBe(5.555);
const resultNoPrecision = randomUtil.randNum(7, 7);
expect(resultNoPrecision).toBe(7);
});
it("should not throw an error when precision is null", () => {
expect(() => randomUtil.randNum(5, 10, null)).not.toThrow();
});
it("should throw when precision is a float or out of bounds", () => {
const maxPrecision = (RandomUtil as any).MAX_PRECISION; // It's private.
const expectedThrow = `randNum() parameter 'precision' must be a positive integer`;
expect(() => randomUtil.randNum(5, 10, 0.5)).toThrowError(expectedThrow);
expect(() => randomUtil.randNum(5, 10, -1)).toThrowError(expectedThrow);
});
it("should use the maximum precision of low and high when precision is null", () => {
const result = randomUtil.randNum(5.123, 10.12345, null);
expect(result.toString().split(".")[1]?.length || 0).toBeLessThanOrEqual(5); // Precision check.
});
it("should round to a whole number when precision is 0", () => {
const result = randomUtil.randNum(5.123, 10.12345, 0);
expect(result.toString().split(".")[1]?.length || 0).toBeLessThanOrEqual(0); // Precision check.
});
it("should correctly handle cases where high is less than low", () => {
expect(() => randomUtil.randNum(10, 5)).not.toThrow();
const result = randomUtil.randNum(10, 5);
expect(result).toBeGreaterThanOrEqual(5);
expect(result).toBeLessThanOrEqual(10);
});
it("should throw an error if low or high are not finite numbers", () => {
const expectedThrow = "randNum() parameters 'value1' and 'value2' must be finite numbers";
expect(() => randomUtil.randNum(Number.POSITIVE_INFINITY, 10)).toThrowError(expectedThrow);
expect(() => randomUtil.randNum(5, Number.NaN)).toThrowError(expectedThrow);
});
it("should always return a value within the inclusive range of low and high", () => {
for (let i = 0; i < 100; i++) {
const result = randomUtil.randNum(5, 6);
expect(result).toBeGreaterThanOrEqual(5);
expect(result).toBeLessThanOrEqual(6);
}
});
it("should return whole numbers when precision is 0", () => {
const result = randomUtil.randNum(5, 10, 0);
expect(result % 1).toBe(0); // Whole number check.
expect(result).toBeGreaterThanOrEqual(5);
expect(result).toBeLessThanOrEqual(10);
});
it("should throw when precision exceeds compatibility with double-precision arithmetic", () => {
const expectedFirstThrow =
"randNum() precision of 16 exceeds the allowable precision (15) for the given values";
expect(() => randomUtil.randNum(0.1, 0.2, 16)).toThrowError(expectedFirstThrow);
const expectedSecondThrow =
"randNum() precision of 12 exceeds the allowable precision (11) for the given values";
expect(() => randomUtil.randNum(1234.1, 1234.2, 12)).toThrowError(expectedSecondThrow);
});
it("should default high to low when high is not provided", () => {
const result = randomUtil.randNum(1);
expect(result).toBeGreaterThanOrEqual(0);
expect(result).toBeLessThanOrEqual(1);
});
it("should handle negative ranges correctly", () => {
const result = randomUtil.randNum(-10, -5);
expect(result).toBeGreaterThanOrEqual(-10);
expect(result).toBeLessThanOrEqual(-5);
});
it("should handle very large numbers correctly", () => {
const result = randomUtil.randNum(1e10, 1e10 + 1);
expect(result).toBeGreaterThanOrEqual(1e10);
expect(result).toBeLessThanOrEqual(1e10 + 1);
});
it("should consistently generate valid results over many iterations", () => {
for (let i = 0; i < 5000; i++) {
const result = randomUtil.randNum(1, 2, 3);
expect(result).toBeGreaterThanOrEqual(1);
expect(result).toBeLessThanOrEqual(2);
expect(result.toString().split(".")[1]?.length || 0).toBeLessThanOrEqual(3); // Precision check
}
});
});
describe("drawRandomFromList", () => {
it("should draw elements with replacement", () => {
const list = [1, 2, 3, 4, 5];