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

Adds RandomUtil.randNum that can generate random integers or floating-point numbers within a range.

Adds a public `randNum` method to the `RandomUtil` class which can be seen as a "superseeded" version of the `randInt` method. It can handle generating random numbers, including both integers or floating-point numbers with support for providing a precision.

Adds a private `getNumberPrecision` method to the `RandomUtil` class which simply counts the number of fractional digits in a number.

Changes `randInt` to log a debug message saying that the ability to pass it a floating-point number is deprecated.
This commit is contained in:
Refringe 2024-12-09 21:31:32 -05:00
parent f278ff9294
commit d3d7a6ed35
Signed by: Refringe
SSH Key Fingerprint: SHA256:t865XsQpfTeqPRBMN2G6+N8wlDjkgUCZF3WGW6O9N/k
2 changed files with 205 additions and 0 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.
*
@ -397,6 +416,9 @@ export class RandomUtil {
// 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") {
@ -417,6 +439,62 @@ export class RandomUtil {
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;
}
/**
* Draws a specified number of random elements from a given list.
*

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;
@ -404,6 +428,12 @@ describe("RandomUtil", () => {
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;
@ -433,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];