Skip to content

Commit d3e60b3

Browse files
mia-pi-gitZarel
andauthoredJan 11, 2025··
Sim: Use a CSPRNG (#10806)
* Sim: Use a CSPRNG * Add test * fix test prng * move prng test to others * fix slight hack * tf? * Fuck this * fucking lol * fix crap * i'm going to kill someone * i hate state * fix test * Good work genius * typo * Fix exportinputlog * Refactor for inputlog backwards compatibility This is a pretty major refactor which is mostly unrelated to the feature, but it does make the code a lot simpler. * Readability pass * Readability (again) * Remove sodium-native dependency * Refactor to serialize seeds in hex strings (Also removes the Buffer dependency from PRNG, and slightly improves comments.) * Apparently << is 32-bit signed * Readability --------- Co-authored-by: Guangcong Luo <guangcongluo@gmail.com>
1 parent 66792c9 commit d3e60b3

27 files changed

+276
-107
lines changed
 

‎data/cg-teams.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1028,7 +1028,7 @@ export default class TeamGenerator {
10281028

10291029
const totalWeight = weights.reduce((a, b) => a + b, 0);
10301030

1031-
let randomWeight = this.prng.next(0, totalWeight);
1031+
let randomWeight = this.prng.random(0, totalWeight);
10321032
for (let i = 0; i < choices.length; i++) {
10331033
randomWeight -= weights[i];
10341034
if (randomWeight < 0) {
@@ -1043,6 +1043,6 @@ export default class TeamGenerator {
10431043
}
10441044

10451045
setSeed(seed: PRNGSeed) {
1046-
this.prng.seed = seed;
1046+
this.prng.setSeed(seed);
10471047
}
10481048
}

‎data/mods/gen2/moves.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,7 @@ export const Moves: import('../../../sim/dex-moves').ModdedMoveDataTable = {
590590
this.debug('Pursuit start');
591591
let alreadyAdded = false;
592592
for (const source of this.effectState.sources) {
593-
if (source.speed < pokemon.speed || (source.speed === pokemon.speed && this.random(2) === 0)) {
593+
if (source.speed < pokemon.speed || (source.speed === pokemon.speed && this.randomChance(1, 2))) {
594594
// Destiny Bond ends if the switch action "outspeeds" the attacker, regardless of host
595595
pokemon.removeVolatile('destinybond');
596596
}

‎data/mods/gen5/conditions.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,7 @@ export const Conditions: import('../../../sim/dex-conditions').ModdedConditionDa
3232
// However, just in case, use 1 if it is undefined.
3333
const counter = this.effectState.counter || 1;
3434
if (counter >= 256) {
35-
// 2^32 - special-cased because Battle.random(n) can't handle n > 2^16 - 1
36-
return (this.random() * 4294967296 < 1);
35+
return this.randomChance(1, 2 ** 32);
3736
}
3837
this.debug("Success chance: " + Math.round(100 / counter) + "%");
3938
return this.randomChance(1, counter);

‎data/mods/gen9ssb/moves.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1248,7 +1248,7 @@ export const Moves: import('../../../sim/dex-moves').ModdedMoveDataTable = {
12481248
target.clearBoosts();
12491249
this.add('-clearboost', target);
12501250
target.addVolatile('protect');
1251-
const set = Math.floor(Math.random() * 4);
1251+
const set = this.random(4);
12521252
const newMoves = [];
12531253
let role = '';
12541254
switch (set) {
@@ -2608,7 +2608,7 @@ export const Moves: import('../../../sim/dex-moves').ModdedMoveDataTable = {
26082608
const spd = target.getStat('spd', false, true);
26092609
const physical = Math.floor(Math.floor(Math.floor(Math.floor(2 * pokemon.level / 5 + 2) * 90 * atk) / def) / 50);
26102610
const special = Math.floor(Math.floor(Math.floor(Math.floor(2 * pokemon.level / 5 + 2) * 90 * spa) / spd) / 50);
2611-
if (physical > special || (physical === special && this.random(2) === 0)) {
2611+
if (physical > special || (physical === special && this.randomChance(1, 2))) {
26122612
move.category = 'Physical';
26132613
move.flags.contact = 1;
26142614
}

‎data/moves.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -7352,7 +7352,7 @@ export const Moves: import('../sim/dex-moves').MoveDataTable = {
73527352
isMax: "Snorlax",
73537353
self: {
73547354
onHit(source) {
7355-
if (this.random(2) === 0) return;
7355+
if (this.randomChance(1, 2)) return;
73567356
for (const pokemon of source.alliesAndSelf()) {
73577357
if (pokemon.item) continue;
73587358

@@ -7448,12 +7448,12 @@ export const Moves: import('../sim/dex-moves').MoveDataTable = {
74487448
isMax: "Grimmsnarl",
74497449
onHit(target) {
74507450
if (target.status || !target.runStatusImmunity('slp')) return;
7451-
if (this.random(2) === 0) return;
7451+
if (this.randomChance(1, 2)) return;
74527452
target.addVolatile('yawn');
74537453
},
74547454
onAfterSubDamage(damage, target) {
74557455
if (target.status || !target.runStatusImmunity('slp')) return;
7456-
if (this.random(2) === 0) return;
7456+
if (this.randomChance(1, 2)) return;
74577457
target.addVolatile('yawn');
74587458
},
74597459
secondary: null,
@@ -16812,7 +16812,7 @@ export const Moves: import('../sim/dex-moves').MoveDataTable = {
1681216812
const spd = target.getStat('spd', false, true);
1681316813
const physical = Math.floor(Math.floor(Math.floor(Math.floor(2 * pokemon.level / 5 + 2) * 90 * atk) / def) / 50);
1681416814
const special = Math.floor(Math.floor(Math.floor(Math.floor(2 * pokemon.level / 5 + 2) * 90 * spa) / spd) / 50);
16815-
if (physical > special || (physical === special && this.random(2) === 0)) {
16815+
if (physical > special || (physical === special && this.randomChance(1, 2))) {
1681616816
move.category = 'Physical';
1681716817
move.flags.contact = 1;
1681816818
}

‎data/random-battles/gen1/teams.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export class RandomGen1Teams extends RandomGen2Teams {
107107
this.enforceNoDirectCustomBanlistChanges();
108108

109109
// Get what we need ready.
110-
const seed = this.prng.seed;
110+
const seed = this.prng.getSeed();
111111
const ruleTable = this.dex.formats.getRuleTable(this.format);
112112
const pokemon: RandomTeamsTypes.RandomSet[] = [];
113113

‎data/random-battles/gen3/teams.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -639,7 +639,7 @@ export class RandomGen3Teams extends RandomGen4Teams {
639639
randomTeam() {
640640
this.enforceNoDirectCustomBanlistChanges();
641641

642-
const seed = this.prng.seed;
642+
const seed = this.prng.getSeed();
643643
const ruleTable = this.dex.formats.getRuleTable(this.format);
644644
const pokemon: RandomTeamsTypes.RandomSet[] = [];
645645

‎data/random-battles/gen5/teams.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -846,7 +846,7 @@ export class RandomGen5Teams extends RandomGen6Teams {
846846
randomTeam() {
847847
this.enforceNoDirectCustomBanlistChanges();
848848

849-
const seed = this.prng.seed;
849+
const seed = this.prng.getSeed();
850850
const ruleTable = this.dex.formats.getRuleTable(this.format);
851851
const pokemon: RandomTeamsTypes.RandomSet[] = [];
852852

‎data/random-battles/gen7/teams.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1182,7 +1182,7 @@ export class RandomGen7Teams extends RandomGen8Teams {
11821182
randomTeam() {
11831183
this.enforceNoDirectCustomBanlistChanges();
11841184

1185-
const seed = this.prng.seed;
1185+
const seed = this.prng.getSeed();
11861186
const ruleTable = this.dex.formats.getRuleTable(this.format);
11871187
const pokemon: RandomTeamsTypes.RandomSet[] = [];
11881188

‎data/random-battles/gen8/teams.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ export class RandomGen8Teams {
270270
}
271271

272272
random(m?: number, n?: number) {
273-
return this.prng.next(m, n);
273+
return this.prng.random(m, n);
274274
}
275275

276276
/**
@@ -2479,7 +2479,7 @@ export class RandomGen8Teams {
24792479
randomTeam() {
24802480
this.enforceNoDirectCustomBanlistChanges();
24812481

2482-
const seed = this.prng.seed;
2482+
const seed = this.prng.getSeed();
24832483
const ruleTable = this.dex.formats.getRuleTable(this.format);
24842484
const pokemon: RandomTeamsTypes.RandomSet[] = [];
24852485

@@ -3112,7 +3112,7 @@ export class RandomGen8Teams {
31123112
for (const speciesName of pokemonPool) {
31133113
const sortObject = {
31143114
speciesName: speciesName,
3115-
score: Math.pow(this.prng.next(), 1 / this.randomBSSFactorySets[speciesName].usage),
3115+
score: Math.pow(this.prng.random(), 1 / this.randomBSSFactorySets[speciesName].usage),
31163116
};
31173117
shuffledSpecies.push(sortObject);
31183118
}

‎data/random-battles/gen9/teams.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ export class RandomTeams {
279279
}
280280

281281
random(m?: number, n?: number) {
282-
return this.prng.next(m, n);
282+
return this.prng.random(m, n);
283283
}
284284

285285
/**
@@ -1632,7 +1632,7 @@ export class RandomTeams {
16321632
randomTeam() {
16331633
this.enforceNoDirectCustomBanlistChanges();
16341634

1635-
const seed = this.prng.seed;
1635+
const seed = this.prng.getSeed();
16361636
const ruleTable = this.dex.formats.getRuleTable(this.format);
16371637
const pokemon: RandomTeamsTypes.RandomSet[] = [];
16381638

@@ -2551,7 +2551,7 @@ export class RandomTeams {
25512551
for (const speciesName of pokemonPool) {
25522552
const sortObject = {
25532553
speciesName,
2554-
score: Math.pow(this.prng.next(), 1 / this.randomFactorySets[this.factoryTier][speciesName].weight),
2554+
score: Math.pow(this.prng.random(), 1 / this.randomFactorySets[this.factoryTier][speciesName].weight),
25552555
};
25562556
shuffledSpecies.push(sortObject);
25572557
}
@@ -2847,7 +2847,7 @@ export class RandomTeams {
28472847
for (const speciesName of pokemonPool) {
28482848
const sortObject = {
28492849
speciesName,
2850-
score: Math.pow(this.prng.next(), 1 / this.randomBSSFactorySets[speciesName].weight),
2850+
score: Math.pow(this.prng.random(), 1 / this.randomBSSFactorySets[speciesName].weight),
28512851
};
28522852
shuffledSpecies.push(sortObject);
28532853
}

‎data/random-battles/gen9baby/teams.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -669,7 +669,7 @@ export class RandomBabyTeams extends RandomTeams {
669669
randomBabyTeam() {
670670
this.enforceNoDirectCustomBanlistChanges();
671671

672-
const seed = this.prng.seed;
672+
const seed = this.prng.getSeed();
673673
const ruleTable = this.dex.formats.getRuleTable(this.format);
674674
const pokemon: RandomTeamsTypes.RandomSet[] = [];
675675

‎data/random-battles/gen9cap/teams.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ export class RandomCAPTeams extends RandomTeams {
185185
randomTeam() {
186186
this.enforceNoDirectCustomBanlistChanges();
187187

188-
const seed = this.prng.seed;
188+
const seed = this.prng.getSeed();
189189
const ruleTable = this.dex.formats.getRuleTable(this.format);
190190
const pokemon: RandomTeamsTypes.RandomSet[] = [];
191191

‎data/rulesets.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2543,7 +2543,7 @@ export const Rulesets: import('../sim/dex-formats').FormatDataTable = {
25432543
const spd = target.getStat('spd', false, true);
25442544
const physical = Math.floor(Math.floor(Math.floor(Math.floor(2 * pokemon.level / 5 + 2) * 90 * atk) / def) / 50);
25452545
const special = Math.floor(Math.floor(Math.floor(Math.floor(2 * pokemon.level / 5 + 2) * 90 * spa) / spd) / 50);
2546-
if (physical > special || (physical === special && this.random(2) === 0)) {
2546+
if (physical > special || (physical === special && this.randomChance(1, 2))) {
25472547
move.category = 'Physical';
25482548
move.flags.contact = 1;
25492549
}

‎lib/utils.ts

+18-1
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,21 @@ export function formatSQLArray(arr: unknown[], args?: unknown[]) {
413413
return [...'?'.repeat(arr.length)].join(', ');
414414
}
415415

416+
export function bufFromHex(hex: string) {
417+
const buf = new Uint8Array(Math.ceil(hex.length / 2));
418+
bufWriteHex(buf, hex);
419+
return buf;
420+
}
421+
export function bufWriteHex(buf: Uint8Array, hex: string, offset = 0) {
422+
const size = Math.ceil(hex.length / 2);
423+
for (let i = 0; i < size; i++) {
424+
buf[offset + i] = parseInt(hex.slice(i * 2, i * 2 + 2).padEnd(2, '0'), 16);
425+
}
426+
}
427+
export function bufReadHex(buf: Uint8Array, start = 0, end?: number) {
428+
return [...buf.slice(start, end)].map(val => val.toString(16).padStart(2, '0')).join('');
429+
}
430+
416431
export class Multiset<T> extends Map<T, number> {
417432
get(key: T) {
418433
return super.get(key) ?? 0;
@@ -436,5 +451,7 @@ export const Utils = {
436451
shuffle, deepClone, clearRequireCache,
437452
randomElement, forceWrap, splitFirst,
438453
stripHTML, visualize, getString,
439-
escapeRegex, formatSQLArray, Multiset,
454+
escapeRegex, formatSQLArray,
455+
bufFromHex, bufReadHex, bufWriteHex,
456+
Multiset,
440457
};

‎package-lock.json

+33-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"preact-render-to-string": "^5.1.19",
1111
"probe-image-size": "^7.2.3",
1212
"sockjs": "^0.3.21",
13-
"source-map-support": "^0.5.21"
13+
"source-map-support": "^0.5.21",
14+
"ts-chacha20": "^1.2.0"
1415
},
1516
"optionalDependencies": {
1617
"better-sqlite3": "^7.6.2",
@@ -72,6 +73,7 @@
7273
"@types/nodemailer": "^6.4.4",
7374
"@types/pg": "^8.6.5",
7475
"@types/sockjs": "^0.3.33",
76+
"@types/sodium-native": "^2.3.9",
7577
"@typescript-eslint/eslint-plugin": "^5.8.0",
7678
"@typescript-eslint/parser": "^5.8.0",
7779
"eslint": "8.5.0",

‎sim/battle-stream.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,12 @@ export class BattleStream extends Streams.ObjectReadWriteStream<string> {
136136
this.battle!.inputLog.push(`>forcelose ${message}`);
137137
break;
138138
case 'reseed':
139-
const seed = message ? message.split(',').map(Number) as PRNGSeed : null;
139+
const seed = message ? message.split(',').map(
140+
n => /[0-9]/.test(n.charAt(0)) ? Number(n) : n
141+
) as PRNGSeed : null;
140142
this.battle!.resetRNG(seed);
141143
// could go inside resetRNG, but this makes using it in `eval` slightly less buggy
142-
this.battle!.inputLog.push(`>reseed ${this.battle!.prng.seed.join(',')}`);
144+
this.battle!.inputLog.push(`>reseed ${this.battle!.prng.getSeed().join(',')}`);
143145
break;
144146
case 'tiebreak':
145147
this.battle!.tiebreak();

‎sim/battle.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ export class Battle {
222222
(format.playerCount > 2 || this.gameType === 'doubles') ? 2 :
223223
1;
224224
this.prng = options.prng || new PRNG(options.seed || undefined);
225-
this.prngSeed = this.prng.startingSeed.slice() as PRNGSeed;
225+
this.prngSeed = this.prng.startingSeed;
226226
this.rated = options.rated || !!options.rated;
227227
this.reportExactHP = !!format.debug;
228228
this.reportPercentages = false;
@@ -273,7 +273,7 @@ export class Battle {
273273
this.send = options.send || (() => {});
274274

275275
const inputOptions: {formatid: ID, seed: PRNGSeed, rated?: string | true} = {
276-
formatid: options.formatid, seed: this.prng.seed,
276+
formatid: options.formatid, seed: this.prngSeed,
277277
};
278278
if (this.rated) inputOptions.rated = this.rated;
279279
if (typeof __version !== 'undefined') {
@@ -340,7 +340,7 @@ export class Battle {
340340
}
341341

342342
random(m?: number, n?: number) {
343-
return this.prng.next(m, n);
343+
return this.prng.random(m, n);
344344
}
345345

346346
randomChance(numerator: number, denominator: number) {
@@ -353,7 +353,7 @@ export class Battle {
353353
}
354354

355355
/** Note that passing `undefined` resets to the starting seed, but `null` will roll a new seed */
356-
resetRNG(seed: PRNGSeed | null = this.prng.startingSeed) {
356+
resetRNG(seed: PRNGSeed | null = this.prngSeed) {
357357
this.prng = new PRNG(seed);
358358
this.add('message', "The battle's RNG was reset.");
359359
}

‎sim/pokemon.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ export class Pokemon {
317317
set.level = this.battle.clampIntRange(set.adjustLevel || set.level || 100, 1, 9999);
318318
this.level = set.level;
319319
const genders: {[key: string]: GenderName} = {M: 'M', F: 'F', N: 'N'};
320-
this.gender = genders[set.gender] || this.species.gender || (this.battle.random() * 2 < 1 ? 'M' : 'F');
320+
this.gender = genders[set.gender] || this.species.gender || (this.battle.random(2) ? 'F' : 'M');
321321
if (this.gender === 'N') this.gender = '';
322322
this.happiness = typeof set.happiness === 'number' ? this.battle.clampIntRange(set.happiness, 0, 255) : 255;
323323
this.pokeball = this.set.pokeball || 'pokeball';

‎sim/prng.ts

+148-46
Original file line numberDiff line numberDiff line change
@@ -12,30 +12,51 @@
1212
* @license MIT license
1313
*/
1414

15+
import {Chacha20} from 'ts-chacha20';
16+
import {Utils} from '../lib/utils';
17+
18+
export type PRNGSeed = SodiumRNGSeed | Gen5RNGSeed;
19+
export type SodiumRNGSeed = ['sodium', string];
1520
/** 64-bit big-endian [high -> low] int */
16-
export type PRNGSeed = [number, number, number, number];
21+
export type Gen5RNGSeed = [number, number, number, number];
1722

1823
/**
19-
* A PRNG intended to emulate the on-cartridge PRNG for Gen 5 with a 64-bit
20-
* initial seed.
24+
* Low-level source of 32-bit random numbers.
25+
*/
26+
interface RNG {
27+
getSeed(): PRNGSeed;
28+
/** random 32-bit number */
29+
next(): number;
30+
}
31+
32+
/**
33+
* High-level PRNG API, for getting random numbers.
34+
*
35+
* Chooses the RNG implementation based on the seed passed to the constructor.
36+
* Seeds starting with 'sodium' use sodium. Other seeds use the Gen 5 RNG.
37+
* If a seed isn't given, defaults to sodium.
38+
*
39+
* The actual randomness source is in this.rng.
2140
*/
2241
export class PRNG {
23-
readonly initialSeed: PRNGSeed;
24-
seed: PRNGSeed;
42+
readonly startingSeed: PRNGSeed;
43+
rng!: RNG;
2544
/** Creates a new source of randomness for the given seed. */
26-
constructor(seed: PRNGSeed | null = null) {
45+
constructor(seed: PRNGSeed | null = null, initialSeed?: PRNGSeed) {
2746
if (!seed) seed = PRNG.generateSeed();
28-
this.initialSeed = seed.slice() as PRNGSeed; // make a copy
29-
this.seed = seed.slice() as PRNGSeed;
47+
this.startingSeed = initialSeed || [...seed]; // make a copy
48+
this.setSeed(seed);
3049
}
3150

32-
/**
33-
* Getter to the initial seed.
34-
*
35-
* This should be considered a hack and is only here for backwards compatibility.
36-
*/
37-
get startingSeed(): PRNGSeed {
38-
return this.initialSeed;
51+
setSeed(seed: PRNGSeed) {
52+
if (seed[0] === 'sodium') {
53+
this.rng = new SodiumRNG(seed);
54+
} else {
55+
this.rng = new Gen5RNG(seed as Gen5RNGSeed);
56+
}
57+
}
58+
getSeed(): PRNGSeed {
59+
return this.rng.getSeed();
3960
}
4061

4162
/**
@@ -44,7 +65,7 @@ export class PRNG {
4465
* The new PRNG will have its initial seed set to the seed of the current instance.
4566
*/
4667
clone(): PRNG {
47-
return new PRNG(this.seed);
68+
return new PRNG(this.rng.getSeed(), this.startingSeed);
4869
}
4970

5071
/**
@@ -55,19 +76,18 @@ export class PRNG {
5576
* - random(m, n) returns an integer in [m, n)
5677
* m and n are converted to integers via Math.floor. If the result is NaN, they are ignored.
5778
*/
58-
next(from?: number, to?: number): number {
59-
this.seed = this.nextFrame(this.seed); // Advance the RNG
60-
let result = (this.seed[0] << 16 >>> 0) + this.seed[1]; // Use the upper 32 bits
79+
random(from?: number, to?: number): number {
80+
const result = this.rng.next();
81+
6182
if (from) from = Math.floor(from);
6283
if (to) to = Math.floor(to);
6384
if (from === undefined) {
64-
result = result / 0x100000000;
85+
return result / 2 ** 32;
6586
} else if (!to) {
66-
result = Math.floor(result * from / 0x100000000);
87+
return Math.floor(result * from / 2 ** 32);
6788
} else {
68-
result = Math.floor(result * (to - from) / 0x100000000) + from;
89+
return Math.floor(result * (to - from) / 2 ** 32) + from;
6990
}
70-
return result;
7191
}
7292

7393
/**
@@ -81,7 +101,7 @@ export class PRNG {
81101
* The denominator must be a positive integer (`> 0`).
82102
*/
83103
randomChance(numerator: number, denominator: number): boolean {
84-
return this.next(denominator) < numerator;
104+
return this.random(denominator) < numerator;
85105
}
86106

87107
/**
@@ -101,7 +121,7 @@ export class PRNG {
101121
if (items.length === 0) {
102122
throw new RangeError(`Cannot sample an empty array`);
103123
}
104-
const index = this.next(items.length);
124+
const index = this.random(items.length);
105125
const item = items[index];
106126
if (item === undefined && !Object.prototype.hasOwnProperty.call(items, index)) {
107127
throw new RangeError(`Cannot sample a sparse array`);
@@ -117,21 +137,94 @@ export class PRNG {
117137
*/
118138
shuffle<T>(items: T[], start = 0, end: number = items.length) {
119139
while (start < end - 1) {
120-
const nextIndex = this.next(start, end);
140+
const nextIndex = this.random(start, end);
121141
if (start !== nextIndex) {
122142
[items[start], items[nextIndex]] = [items[nextIndex], items[start]];
123143
}
124144
start++;
125145
}
126146
}
127147

148+
static generateSeed(): SodiumRNGSeed {
149+
return [
150+
'sodium',
151+
// 32 bits each, 128 bits total (16 bytes)
152+
Math.trunc(Math.random() * 2 ** 32).toString(16).padStart(8, '0') +
153+
Math.trunc(Math.random() * 2 ** 32).toString(16).padStart(8, '0') +
154+
Math.trunc(Math.random() * 2 ** 32).toString(16).padStart(8, '0') +
155+
Math.trunc(Math.random() * 2 ** 32).toString(16).padStart(8, '0'),
156+
];
157+
}
158+
}
159+
160+
/**
161+
* This is a drop-in replacement for libsodium's randombytes_buf_deterministic,
162+
* but it's implemented with ts-chacha20 instead, for a smaller dependency that
163+
* doesn't use NodeJS native modules, for better portability.
164+
*/
165+
export class SodiumRNG implements RNG {
166+
// nonce chosen to be compatible with libsodium's randombytes_buf_deterministic
167+
// https://github.com/jedisct1/libsodium/blob/ce07d6c82c0e6c75031cf627913bf4f9d3f1e754/src/libsodium/randombytes/randombytes.c#L178
168+
static readonly NONCE = Uint8Array.from([..."LibsodiumDRG"].map(c => c.charCodeAt(0)));
169+
seed!: Uint8Array;
170+
/** Creates a new source of randomness for the given seed. */
171+
constructor(seed: SodiumRNGSeed) {
172+
this.setSeed(seed);
173+
}
174+
175+
setSeed(seed: SodiumRNGSeed) {
176+
// randombytes_buf_deterministic requires 32 bytes, but
177+
// generateSeed generates 16 bytes, so the last 16 bytes will be 0
178+
// when starting out. This shouldn't cause any problems.
179+
const seedBuf = new Uint8Array(32);
180+
Utils.bufWriteHex(seedBuf, seed[1].padEnd(64, '0'));
181+
this.seed = seedBuf;
182+
}
183+
getSeed(): SodiumRNGSeed {
184+
return ['sodium', Utils.bufReadHex(this.seed)];
185+
}
186+
187+
next() {
188+
const zeroBuf = new Uint8Array(36);
189+
// tested to do the exact same thing as
190+
// sodium.randombytes_buf_deterministic(buf, this.seed);
191+
const buf = new Chacha20(this.seed, SodiumRNG.NONCE).encrypt(zeroBuf);
192+
193+
// use the first 32 bytes for the next seed, and the next 4 bytes for the output
194+
this.seed = buf.slice(0, 32);
195+
// reading big-endian
196+
return buf.slice(32, 36).reduce((a, b) => a * 256 + b);
197+
// alternative, probably slower (TODO: benchmark)
198+
// return parseInt(Utils.bufReadHex(buf, 32, 36), 16);
199+
}
200+
}
201+
202+
/**
203+
* A PRNG intended to emulate the on-cartridge PRNG for Gen 5 with a 64-bit
204+
* initial seed.
205+
*/
206+
export class Gen5RNG implements RNG {
207+
seed: Gen5RNGSeed;
208+
/** Creates a new source of randomness for the given seed. */
209+
constructor(seed: Gen5RNGSeed | null = null) {
210+
this.seed = [...seed || Gen5RNG.generateSeed()];
211+
}
212+
213+
getSeed() {
214+
return this.seed;
215+
}
216+
217+
next(): number {
218+
this.seed = this.nextFrame(this.seed); // Advance the RNG
219+
return (this.seed[0] << 16 >>> 0) + this.seed[1]; // Use the upper 32 bits
220+
}
221+
128222
/**
129223
* Calculates `a * b + c` (with 64-bit 2's complement integers)
130-
*
131-
* If you've done long multiplication, this is the same thing.
132224
*/
133-
multiplyAdd(a: PRNGSeed, b: PRNGSeed, c: PRNGSeed) {
134-
const out: PRNGSeed = [0, 0, 0, 0];
225+
multiplyAdd(a: Gen5RNGSeed, b: Gen5RNGSeed, c: Gen5RNGSeed) {
226+
// If you've done long multiplication, this is the same thing.
227+
const out: Gen5RNGSeed = [0, 0, 0, 0];
135228
let carry = 0;
136229

137230
for (let outIndex = 3; outIndex >= 0; outIndex--) {
@@ -160,39 +253,48 @@ export class PRNG {
160253
* m = 2^64
161254
* ````
162255
*/
163-
nextFrame(seed: PRNGSeed, framesToAdvance = 1): PRNGSeed {
164-
const a: PRNGSeed = [0x5D58, 0x8B65, 0x6C07, 0x8965];
165-
const c: PRNGSeed = [0, 0, 0x26, 0x9EC3];
256+
nextFrame(seed: Gen5RNGSeed, framesToAdvance = 1): Gen5RNGSeed {
257+
const a: Gen5RNGSeed = [0x5D58, 0x8B65, 0x6C07, 0x8965];
258+
const c: Gen5RNGSeed = [0, 0, 0x26, 0x9EC3];
166259

167260
for (let i = 0; i < framesToAdvance; i++) {
261+
// seed = seed * a + c
168262
seed = this.multiplyAdd(seed, a, c);
169263
}
170264

171265
return seed;
172266
}
173267

174-
static generateSeed() {
268+
static generateSeed(): Gen5RNGSeed {
175269
return [
176-
Math.floor(Math.random() * 0x10000),
177-
Math.floor(Math.random() * 0x10000),
178-
Math.floor(Math.random() * 0x10000),
179-
Math.floor(Math.random() * 0x10000),
180-
] as PRNGSeed;
270+
Math.trunc(Math.random() * 2 ** 16),
271+
Math.trunc(Math.random() * 2 ** 16),
272+
Math.trunc(Math.random() * 2 ** 16),
273+
Math.trunc(Math.random() * 2 ** 16),
274+
];
181275
}
182276
}
183277

184-
// The following commented-out function is designed to emulate the on-cartridge
278+
// The following commented-out class is designed to emulate the on-cartridge
185279
// PRNG for Gens 3 and 4, as described in
186280
// https://www.smogon.com/ingame/rng/pid_iv_creation#pokemon_random_number_generator
187281
// This RNG uses a 32-bit initial seed
188282
// m and n are converted to integers via Math.floor. If the result is NaN, they
189283
// are ignored.
190284
/*
191-
random(m: number, n: number) {
192-
this.seed = (this.seed * 0x41C64E6D + 0x6073) >>> 0; // truncate the result to the last 32 bits
193-
let result = this.seed >>> 16; // the first 16 bits of the seed are the random value
194-
m = Math.floor(m)
195-
n = Math.floor(n)
196-
return (m ? (n ? (result % (n - m)) + m : result % m) : result / 0x10000)
285+
export type Gen3RNGSeed = ['gen3', number];
286+
export class Gen3RNG implements RNG {
287+
seed: number;
288+
constructor(seed: Gen3RNGSeed | null = null) {
289+
this.seed = seed ? seed[1] : Math.trunc(Math.random() * 2 ** 32);
290+
}
291+
getSeed() {
292+
return ['gen3', this.seed];
293+
}
294+
next(): number {
295+
this.seed = this.seed * 0x41C64E6D + 0x6073) >>> 0; // truncate the result to the last 32 bits
296+
const val = this.seed >>> 16; // the first 16 bits of the seed are the random value
297+
return val << 16 >>> 0; // PRNG#random expects a 32-bit number and will divide accordingly
298+
}
197299
}
198300
*/

‎sim/state.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export const State = new class {
6666
for (const [i, side] of battle.sides.entries()) {
6767
state.sides[i] = this.serializeSide(side);
6868
}
69-
state.prng = battle.prng.seed;
69+
state.prng = battle.prng.getSeed();
7070
state.hints = Array.from(battle.hints);
7171
// We treat log specially because we only set it back on Battle after everything
7272
// else has been deserialized to avoid anything accidentally `add`-ing to it.

‎sim/tools/exhaustive-runner.ts

+8-8
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export class ExhaustiveRunner {
7373
async run() {
7474
const dex = Dex.forFormat(this.format);
7575

76-
const seed = this.prng.seed;
76+
const seed = this.prng.getSeed();
7777
const pools = this.createPools(dex);
7878
const createAI = (s: ObjectReadWriteStream<string>, o: AIOptions) => new CoordinatedPlayerAI(s, o, pools);
7979
const generator = new TeamGenerator(dex, this.prng, pools, ExhaustiveRunner.getSignatures(dex, pools));
@@ -216,13 +216,13 @@ class TeamGenerator {
216216
const team: PokemonSet[] = [];
217217
for (const pokemon of this.pools.pokemon.next(6)) {
218218
const species = this.dex.species.get(pokemon);
219-
const randomEVs = () => this.prng.next(253);
220-
const randomIVs = () => this.prng.next(32);
219+
const randomEVs = () => this.prng.random(253);
220+
const randomIVs = () => this.prng.random(32);
221221

222222
let item;
223223
const moves = [];
224224
const combos = this.signatures.get(species.id);
225-
if (combos && this.prng.next() > TeamGenerator.COMBO) {
225+
if (combos && this.prng.random() > TeamGenerator.COMBO) {
226226
const combo = this.prng.sample(combos);
227227
item = combo.item;
228228
if (combo.move) moves.push(combo.move);
@@ -254,8 +254,8 @@ class TeamGenerator {
254254
spe: randomIVs(),
255255
},
256256
nature: this.prng.sample(this.natures),
257-
level: this.prng.next(50, 100),
258-
happiness: this.prng.next(256),
257+
level: this.prng.random(50, 100),
258+
happiness: this.prng.random(256),
259259
shiny: this.prng.randomChance(1, 1024),
260260
});
261261
}
@@ -309,7 +309,7 @@ class Pool {
309309

310310
private shuffle<T>(arr: T[]): T[] {
311311
for (let i = arr.length - 1; i > 0; i--) {
312-
const j = Math.floor(this.prng.next() * (i + 1));
312+
const j = this.prng.random(i + 1);
313313
[arr[i], arr[j]] = [arr[j], arr[i]];
314314
}
315315
return arr;
@@ -383,7 +383,7 @@ class Pool {
383383
this.filler = this.possible.slice();
384384
length = this.filler.length;
385385
}
386-
const index = this.prng.next(length);
386+
const index = this.prng.random(length);
387387
const element = this.filler![index];
388388
this.filler![index] = this.filler![length - 1];
389389
this.filler!.pop();

‎sim/tools/multi-random-runner.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export class MultiRandomRunner {
7171
games = [];
7272
}
7373

74-
const seed = this.prng.seed;
74+
const seed = this.prng.getSeed();
7575
const game = new Runner({format, ...this.options}).run().catch(err => {
7676
failures++;
7777
console.error(

‎sim/tools/random-player-ai.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export class RandomPlayerAI extends BattlePlayer {
7979
canTerastallize = canTerastallize && !!active.canTerastallize;
8080

8181
// Determine whether we should change form if we do end up switching
82-
const change = (canMegaEvo || canUltraBurst || canDynamax) && this.prng.next() < this.mega;
82+
const change = (canMegaEvo || canUltraBurst || canDynamax) && this.prng.random() < this.mega;
8383
// If we've already dynamaxed or if we're planning on potentially dynamaxing
8484
// we need to use the maxMoves instead of our regular moves
8585

@@ -120,14 +120,14 @@ export class RandomPlayerAI extends BattlePlayer {
120120
// NOTE: We don't generate all possible targeting combinations.
121121
if (request.active.length > 1) {
122122
if ([`normal`, `any`, `adjacentFoe`].includes(m.target)) {
123-
move += ` ${1 + Math.floor(this.prng.next() * 2)}`;
123+
move += ` ${1 + this.prng.random(2)}`;
124124
}
125125
if (m.target === `adjacentAlly`) {
126126
move += ` -${(i ^ 1) + 1}`;
127127
}
128128
if (m.target === `adjacentAllyOrSelf`) {
129129
if (hasAlly) {
130-
move += ` -${1 + Math.floor(this.prng.next() * 2)}`;
130+
move += ` -${1 + this.prng.random(2)}`;
131131
} else {
132132
move += ` -${i + 1}`;
133133
}
@@ -148,7 +148,7 @@ export class RandomPlayerAI extends BattlePlayer {
148148
));
149149
const switches = active.trapped ? [] : canSwitch;
150150

151-
if (switches.length && (!moves.length || this.prng.next() > this.move)) {
151+
if (switches.length && (!moves.length || this.prng.random() > this.move)) {
152152
const target = this.chooseSwitch(
153153
active,
154154
canSwitch.map(slot => ({slot, pokemon: pokemon[slot - 1]}))

‎sim/tools/runner.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export class Runner {
8686
private async runGame(format: string, battleStream: RawBattleStream | DualStream) {
8787
// @ts-ignore - DualStream implements everything relevant from BattleStream.
8888
const streams = BattleStreams.getPlayerStreams(battleStream);
89-
const spec = {formatid: format, seed: this.prng.seed};
89+
const spec = {formatid: format, seed: this.prng.getSeed()};
9090
const is4P = Dex.formats.get(format).playerCount > 2;
9191
const p1spec = this.getPlayerSpec("Bot 1", this.p1options);
9292
const p2spec = this.getPlayerSpec("Bot 2", this.p2options);
@@ -140,10 +140,10 @@ export class Runner {
140140
// NOTE: advances this.prng's seed by 4.
141141
private newSeed(): PRNGSeed {
142142
return [
143-
Math.floor(this.prng.next() * 0x10000),
144-
Math.floor(this.prng.next() * 0x10000),
145-
Math.floor(this.prng.next() * 0x10000),
146-
Math.floor(this.prng.next() * 0x10000),
143+
this.prng.random(2 ** 16),
144+
this.prng.random(2 ** 16),
145+
this.prng.random(2 ** 16),
146+
this.prng.random(2 ** 16),
147147
];
148148
}
149149

‎test/sim/misc/prng.js

+22-7
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,24 @@
33
const PRNG = require('../../../dist/sim/prng').PRNG;
44
const assert = require('../../assert');
55

6-
const testSeed = [1, 2, 3, 4];
6+
const testSeed = ['sodium', '00000001000000020000000300000004'];
77

88
describe(`PRNG`, function () {
9+
it("should always generate the same results off the same seed", function () {
10+
const results = [];
11+
const testAgainst = new PRNG(testSeed);
12+
for (let i = 0; i < 100; i++) {
13+
results.push(testAgainst.random());
14+
}
15+
for (let i = 0; i < 10; i++) {
16+
const cur = new PRNG(testSeed);
17+
for (let j = 0; j < results.length; j++) {
18+
const n = cur.random();
19+
assert(results[j] === n, `generation ${j} for seed ${testSeed} did not match (expected: ${results[j]}, got ${n})`);
20+
}
21+
}
22+
});
23+
924
describe(`randomChance(numerator=0, denominator=1)`, function () {
1025
it(`should always return false`, function () {
1126
const prng = new PRNG(testSeed);
@@ -41,12 +56,12 @@ describe(`PRNG`, function () {
4156
}
4257
assert.bounded(trueCount, [45, 55]);
4358
});
44-
it(`should be identical to (next(2) == 0)`, function () {
59+
it(`should be identical to (random(2) == 0)`, function () {
4560
// This invariant is important for battle logs.
4661
const coinPRNG = new PRNG(testSeed);
4762
const numberPRNG = new PRNG(testSeed);
4863
for (let i = 0; i < 10; ++i) {
49-
assert.equal(numberPRNG.next(2) === 0, coinPRNG.randomChance(1, 2));
64+
assert.equal(numberPRNG.random(2) === 0, coinPRNG.randomChance(1, 2));
5065
}
5166
});
5267
});
@@ -61,12 +76,12 @@ describe(`PRNG`, function () {
6176
}
6277
assert.bounded(trueCount, [80, 90]);
6378
});
64-
it(`should be identical to (next(256) < 217)`, function () {
79+
it(`should be identical to (random(256) < 217)`, function () {
6580
// This invariant is important for battle logs.
6681
const coinPRNG = new PRNG(testSeed);
6782
const numberPRNG = new PRNG(testSeed);
6883
for (let i = 0; i < 10; ++i) {
69-
assert.equal(numberPRNG.next(256) < 217, coinPRNG.randomChance(217, 256));
84+
assert.equal(numberPRNG.random(256) < 217, coinPRNG.randomChance(217, 256));
7085
}
7186
});
7287
});
@@ -129,13 +144,13 @@ describe(`PRNG`, function () {
129144
assert.bounded(occurences.x, [63, 71]);
130145
assert.bounded(occurences.y, [29, 37]);
131146
});
132-
it(`should be identical to array[next(array.length)]`, function () {
147+
it(`should be identical to array[random(array.length)]`, function () {
133148
// This invariant is important for battle logs.
134149
const items = [{}, {}, {}, {}, {}, {}, {}, {}];
135150
const samplePRNG = new PRNG(testSeed);
136151
const randomIntegerPRNG = new PRNG(testSeed);
137152
for (let i = 0; i < 10; ++i) {
138-
assert.equal(items[randomIntegerPRNG.next(items.length)], samplePRNG.sample(items));
153+
assert.equal(items[randomIntegerPRNG.random(items.length)], samplePRNG.sample(items));
139154
}
140155
});
141156
});

0 commit comments

Comments
 (0)
Please sign in to comment.