From 243b45606170f01d76aec06f3e75ce8114ccf694 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sat, 16 Aug 2025 18:03:25 -0700 Subject: [PATCH] Use seedrandom to back PseudoRandom.ts (#1828) The previous implementation had a bug that biased numbers away from 0, so random.chance(1500+) would always return false. This caused trains to not spawn at all when their spawn rate was sufficiently low. We should be using a library instead of implementing it from scratch anyways. - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced regression is found: evan --- package-lock.json | 15 +++++ package.json | 2 + src/core/PseudoRandom.ts | 124 +++++++-------------------------------- 3 files changed, 38 insertions(+), 103 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5b9b6d6f8..6b0ba88a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "js-yaml": "^4.1.0", "nanoid": "^3.3.6", "obscenity": "^0.4.3", + "seedrandom": "^3.0.5", "ts-node": "^10.9.2", "uuid": "^11.1.0", "winston": "^3.17.0", @@ -58,6 +59,7 @@ "@types/msgpack5": "^3.4.6", "@types/node": "^22.10.2", "@types/pg": "^8.11.11", + "@types/seedrandom": "^3.0.8", "@types/sinon": "^17.0.3", "@types/systeminformation": "^3.23.1", "@types/ws": "^8.5.11", @@ -7347,6 +7349,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/seedrandom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-3.0.8.tgz", + "integrity": "sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/send": { "version": "0.17.5", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", @@ -17985,6 +17994,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "license": "MIT" + }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", diff --git a/package.json b/package.json index be092a2b6..9f131e0ec 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@types/msgpack5": "^3.4.6", "@types/node": "^22.10.2", "@types/pg": "^8.11.11", + "@types/seedrandom": "^3.0.8", "@types/sinon": "^17.0.3", "@types/systeminformation": "^3.23.1", "@types/ws": "^8.5.11", @@ -123,6 +124,7 @@ "js-yaml": "^4.1.0", "nanoid": "^3.3.6", "obscenity": "^0.4.3", + "seedrandom": "^3.0.5", "ts-node": "^10.9.2", "uuid": "^11.1.0", "winston": "^3.17.0", diff --git a/src/core/PseudoRandom.ts b/src/core/PseudoRandom.ts index 35e092275..d0670ba5f 100644 --- a/src/core/PseudoRandom.ts +++ b/src/core/PseudoRandom.ts @@ -1,123 +1,45 @@ -export class PseudoRandom { - // Internal state (two 32-bit integers) - private state0: number; - private state1: number; +import seedrandom from "seedrandom"; - // Keep these variables to maintain the exact same interface - private m = 0x80000000; // 2**31 - private a = 1103515245; - private c = 12345; - private state: number; +export class PseudoRandom { + private rng: seedrandom.PRNG; private static readonly POW36_8 = Math.pow(36, 8); // Pre-compute 36^8 - private static readonly INV_2_32 = 1 / 4294967296; // 1 / 2^32 for float conversion constructor(seed: number) { - // Initialize the XorShift state with seed - this.state0 = seed | 0; // Force to 32-bit integer with bitwise OR - this.state1 = 0x6e2d786c; // Fixed value as second seed (arbitrary prime) - - // Ensure non-zero state - if (this.state0 === 0) this.state0 = 1; - - // Also set the LCG state variable to maintain interface - this.state = seed % this.m; - if (this.state < 0) this.state += this.m; - - // Warm up the generator to improve initial distribution - for (let i = 0; i < 20; i++) { - this._nextIntInternal(); - } + this.rng = seedrandom(String(seed)); } - /** - * Internal function that implements XorShift algorithm - * @returns A 32-bit integer - */ - private _nextIntInternal(): number { - // Get current state - let s1 = this.state0; - const s0 = this.state1; - - // Update state using XorShift algorithm (all operations are bitwise) - this.state0 = s0; - s1 ^= s1 << 23; - s1 ^= s1 >>> 17; - s1 ^= s0; - s1 ^= s0 >>> 26; - this.state1 = s1; - - // Generate output (force 32-bit integer) - return (this.state0 + this.state1) | 0; - } - - /** - * Optimized version that directly returns unsigned 32-bit integer - */ - private _nextUInt32(): number { - return this._nextIntInternal() >>> 0; - } - - /** - * Generates the next pseudorandom number. - * @returns A number between 0 (inclusive) and 1 (exclusive). - */ + // Generates the next pseudorandom number between 0 and 1. next(): number { - // Get a 32-bit integer and convert to [0,1) range - // Using >>> 0 to get unsigned interpretation (positive number) - const int = this._nextUInt32(); - - // Update the state variable to maintain compatibility with original interface - this.state = int % this.m; - - // Convert to [0,1) range - using division for same interface - return this.state / this.m; + return this.rng(); } - /** - * Optimized version for internal use - directly converts to [0,1) without state update - */ - private _nextFloat(): number { - return this._nextUInt32() * PseudoRandom.INV_2_32; - } - - /** - * Generates a random integer between min (inclusive) and max (exclusive). - */ + // Generates a random integer between min (inclusive) and max (exclusive). nextInt(min: number, max: number): number { - // keep max exclusive, min inclusive – round down to get an int - return Math.floor(this._nextFloat() * (max - min)) + min; + return Math.floor(this.rng() * (max - min)) + min; } - /** - * Generates a random float between min (inclusive) and max (exclusive). - */ + // Generates a random float between min (inclusive) and max (exclusive). nextFloat(min: number, max: number): number { - return this._nextFloat() * (max - min) + min; + return this.rng() * (max - min) + min; } - /** - * Generates a random ID (8 characters, alphanumeric). - */ + // Generates a random ID (8 characters, alphanumeric). nextID(): string { - return Math.floor(this._nextFloat() * PseudoRandom.POW36_8) // 36^8 possibilities - .toString(36) // Convert to base36 (0-9 and a-z) - .padStart(8, "0"); // Ensure 8 chars by padding with zeros + return Math.floor(this.rng() * PseudoRandom.POW36_8) + .toString(36) + .padStart(8, "0"); } - /** - * Selects a random element from an array. - */ + // Selects a random element from an array. randElement(arr: T[]): T { if (arr.length === 0) { throw new Error("array must not be empty"); } - return arr[Math.floor(this._nextFloat() * arr.length)]; + return arr[Math.floor(this.rng() * arr.length)]; } - /** - * Selects a random element from a set. - */ + // Selects a random element from a set. randFromSet(set: Set): T { const size = set.size; if (size === 0) { @@ -137,20 +59,16 @@ export class PseudoRandom { throw new Error("Unexpected error selecting element from set"); } - /** - * Returns true with probability 1/odds. - */ + // Returns true with probability 1/odds. chance(odds: number): boolean { - return Math.floor(this._nextFloat() * odds) === 0; + return Math.floor(this.rng() * odds) === 0; } - /** - * Returns a shuffled copy of the array using Fisher-Yates algorithm. - */ + // Returns a shuffled copy of the array using Fisher-Yates algorithm. shuffleArray(array: T[]): T[] { const result = [...array]; for (let i = result.length - 1; i >= 0; i--) { - const j = Math.floor(this._nextFloat() * (i + 1)); + const j = Math.floor(this.rng() * (i + 1)); [result[i], result[j]] = [result[j], result[i]]; } return result;