diff --git a/data/cg-teams.ts b/data/cg-teams.ts index 430d3dec2a09..026d6ba51089 100644 --- a/data/cg-teams.ts +++ b/data/cg-teams.ts @@ -1028,7 +1028,7 @@ export default class TeamGenerator { const totalWeight = weights.reduce((a, b) => a + b, 0); - let randomWeight = this.prng.next(0, totalWeight); + let randomWeight = this.prng.random(0, totalWeight); for (let i = 0; i < choices.length; i++) { randomWeight -= weights[i]; if (randomWeight < 0) { @@ -1043,6 +1043,6 @@ export default class TeamGenerator { } setSeed(seed: PRNGSeed) { - this.prng.seed = seed; + this.prng.setSeed(seed); } } diff --git a/data/mods/gen2/moves.ts b/data/mods/gen2/moves.ts index 634f0e89341f..58797d872151 100644 --- a/data/mods/gen2/moves.ts +++ b/data/mods/gen2/moves.ts @@ -590,7 +590,7 @@ export const Moves: import('../../../sim/dex-moves').ModdedMoveDataTable = { this.debug('Pursuit start'); let alreadyAdded = false; for (const source of this.effectState.sources) { - if (source.speed < pokemon.speed || (source.speed === pokemon.speed && this.random(2) === 0)) { + if (source.speed < pokemon.speed || (source.speed === pokemon.speed && this.randomChance(1, 2))) { // Destiny Bond ends if the switch action "outspeeds" the attacker, regardless of host pokemon.removeVolatile('destinybond'); } diff --git a/data/mods/gen5/conditions.ts b/data/mods/gen5/conditions.ts index 5193df8eeca9..ed35741577fd 100644 --- a/data/mods/gen5/conditions.ts +++ b/data/mods/gen5/conditions.ts @@ -32,8 +32,7 @@ export const Conditions: import('../../../sim/dex-conditions').ModdedConditionDa // However, just in case, use 1 if it is undefined. const counter = this.effectState.counter || 1; if (counter >= 256) { - // 2^32 - special-cased because Battle.random(n) can't handle n > 2^16 - 1 - return (this.random() * 4294967296 < 1); + return this.randomChance(1, 2 ** 32); } this.debug("Success chance: " + Math.round(100 / counter) + "%"); return this.randomChance(1, counter); diff --git a/data/mods/gen9ssb/moves.ts b/data/mods/gen9ssb/moves.ts index 35c1284202f1..cdbef5356fb5 100644 --- a/data/mods/gen9ssb/moves.ts +++ b/data/mods/gen9ssb/moves.ts @@ -1248,7 +1248,7 @@ export const Moves: import('../../../sim/dex-moves').ModdedMoveDataTable = { target.clearBoosts(); this.add('-clearboost', target); target.addVolatile('protect'); - const set = Math.floor(Math.random() * 4); + const set = this.random(4); const newMoves = []; let role = ''; switch (set) { @@ -2608,7 +2608,7 @@ export const Moves: import('../../../sim/dex-moves').ModdedMoveDataTable = { const spd = target.getStat('spd', false, true); const physical = Math.floor(Math.floor(Math.floor(Math.floor(2 * pokemon.level / 5 + 2) * 90 * atk) / def) / 50); const special = Math.floor(Math.floor(Math.floor(Math.floor(2 * pokemon.level / 5 + 2) * 90 * spa) / spd) / 50); - if (physical > special || (physical === special && this.random(2) === 0)) { + if (physical > special || (physical === special && this.randomChance(1, 2))) { move.category = 'Physical'; move.flags.contact = 1; } diff --git a/data/moves.ts b/data/moves.ts index d66ea384c30b..f850aa8b5ecc 100644 --- a/data/moves.ts +++ b/data/moves.ts @@ -7352,7 +7352,7 @@ export const Moves: import('../sim/dex-moves').MoveDataTable = { isMax: "Snorlax", self: { onHit(source) { - if (this.random(2) === 0) return; + if (this.randomChance(1, 2)) return; for (const pokemon of source.alliesAndSelf()) { if (pokemon.item) continue; @@ -7448,12 +7448,12 @@ export const Moves: import('../sim/dex-moves').MoveDataTable = { isMax: "Grimmsnarl", onHit(target) { if (target.status || !target.runStatusImmunity('slp')) return; - if (this.random(2) === 0) return; + if (this.randomChance(1, 2)) return; target.addVolatile('yawn'); }, onAfterSubDamage(damage, target) { if (target.status || !target.runStatusImmunity('slp')) return; - if (this.random(2) === 0) return; + if (this.randomChance(1, 2)) return; target.addVolatile('yawn'); }, secondary: null, @@ -16812,7 +16812,7 @@ export const Moves: import('../sim/dex-moves').MoveDataTable = { const spd = target.getStat('spd', false, true); const physical = Math.floor(Math.floor(Math.floor(Math.floor(2 * pokemon.level / 5 + 2) * 90 * atk) / def) / 50); const special = Math.floor(Math.floor(Math.floor(Math.floor(2 * pokemon.level / 5 + 2) * 90 * spa) / spd) / 50); - if (physical > special || (physical === special && this.random(2) === 0)) { + if (physical > special || (physical === special && this.randomChance(1, 2))) { move.category = 'Physical'; move.flags.contact = 1; } diff --git a/data/random-battles/gen1/teams.ts b/data/random-battles/gen1/teams.ts index 2ce6a27ef455..59b8bee73301 100644 --- a/data/random-battles/gen1/teams.ts +++ b/data/random-battles/gen1/teams.ts @@ -107,7 +107,7 @@ export class RandomGen1Teams extends RandomGen2Teams { this.enforceNoDirectCustomBanlistChanges(); // Get what we need ready. - const seed = this.prng.seed; + const seed = this.prng.getSeed(); const ruleTable = this.dex.formats.getRuleTable(this.format); const pokemon: RandomTeamsTypes.RandomSet[] = []; diff --git a/data/random-battles/gen3/teams.ts b/data/random-battles/gen3/teams.ts index b67d2e3f65ca..3865ed7030c2 100644 --- a/data/random-battles/gen3/teams.ts +++ b/data/random-battles/gen3/teams.ts @@ -639,7 +639,7 @@ export class RandomGen3Teams extends RandomGen4Teams { randomTeam() { this.enforceNoDirectCustomBanlistChanges(); - const seed = this.prng.seed; + const seed = this.prng.getSeed(); const ruleTable = this.dex.formats.getRuleTable(this.format); const pokemon: RandomTeamsTypes.RandomSet[] = []; diff --git a/data/random-battles/gen5/teams.ts b/data/random-battles/gen5/teams.ts index 0454eef66335..0ec0912b1c86 100644 --- a/data/random-battles/gen5/teams.ts +++ b/data/random-battles/gen5/teams.ts @@ -846,7 +846,7 @@ export class RandomGen5Teams extends RandomGen6Teams { randomTeam() { this.enforceNoDirectCustomBanlistChanges(); - const seed = this.prng.seed; + const seed = this.prng.getSeed(); const ruleTable = this.dex.formats.getRuleTable(this.format); const pokemon: RandomTeamsTypes.RandomSet[] = []; diff --git a/data/random-battles/gen7/teams.ts b/data/random-battles/gen7/teams.ts index d7fb776a4063..413e3767ca67 100644 --- a/data/random-battles/gen7/teams.ts +++ b/data/random-battles/gen7/teams.ts @@ -1182,7 +1182,7 @@ export class RandomGen7Teams extends RandomGen8Teams { randomTeam() { this.enforceNoDirectCustomBanlistChanges(); - const seed = this.prng.seed; + const seed = this.prng.getSeed(); const ruleTable = this.dex.formats.getRuleTable(this.format); const pokemon: RandomTeamsTypes.RandomSet[] = []; diff --git a/data/random-battles/gen8/teams.ts b/data/random-battles/gen8/teams.ts index 210968ed3df2..b403bd38bd21 100644 --- a/data/random-battles/gen8/teams.ts +++ b/data/random-battles/gen8/teams.ts @@ -270,7 +270,7 @@ export class RandomGen8Teams { } random(m?: number, n?: number) { - return this.prng.next(m, n); + return this.prng.random(m, n); } /** @@ -2479,7 +2479,7 @@ export class RandomGen8Teams { randomTeam() { this.enforceNoDirectCustomBanlistChanges(); - const seed = this.prng.seed; + const seed = this.prng.getSeed(); const ruleTable = this.dex.formats.getRuleTable(this.format); const pokemon: RandomTeamsTypes.RandomSet[] = []; @@ -3112,7 +3112,7 @@ export class RandomGen8Teams { for (const speciesName of pokemonPool) { const sortObject = { speciesName: speciesName, - score: Math.pow(this.prng.next(), 1 / this.randomBSSFactorySets[speciesName].usage), + score: Math.pow(this.prng.random(), 1 / this.randomBSSFactorySets[speciesName].usage), }; shuffledSpecies.push(sortObject); } diff --git a/data/random-battles/gen9/teams.ts b/data/random-battles/gen9/teams.ts index c3df7c8609ea..f2ec331f626c 100644 --- a/data/random-battles/gen9/teams.ts +++ b/data/random-battles/gen9/teams.ts @@ -279,7 +279,7 @@ export class RandomTeams { } random(m?: number, n?: number) { - return this.prng.next(m, n); + return this.prng.random(m, n); } /** @@ -1632,7 +1632,7 @@ export class RandomTeams { randomTeam() { this.enforceNoDirectCustomBanlistChanges(); - const seed = this.prng.seed; + const seed = this.prng.getSeed(); const ruleTable = this.dex.formats.getRuleTable(this.format); const pokemon: RandomTeamsTypes.RandomSet[] = []; @@ -2551,7 +2551,7 @@ export class RandomTeams { for (const speciesName of pokemonPool) { const sortObject = { speciesName, - score: Math.pow(this.prng.next(), 1 / this.randomFactorySets[this.factoryTier][speciesName].weight), + score: Math.pow(this.prng.random(), 1 / this.randomFactorySets[this.factoryTier][speciesName].weight), }; shuffledSpecies.push(sortObject); } @@ -2847,7 +2847,7 @@ export class RandomTeams { for (const speciesName of pokemonPool) { const sortObject = { speciesName, - score: Math.pow(this.prng.next(), 1 / this.randomBSSFactorySets[speciesName].weight), + score: Math.pow(this.prng.random(), 1 / this.randomBSSFactorySets[speciesName].weight), }; shuffledSpecies.push(sortObject); } diff --git a/data/random-battles/gen9baby/teams.ts b/data/random-battles/gen9baby/teams.ts index 1cc922763a17..99ba41ae5d90 100644 --- a/data/random-battles/gen9baby/teams.ts +++ b/data/random-battles/gen9baby/teams.ts @@ -669,7 +669,7 @@ export class RandomBabyTeams extends RandomTeams { randomBabyTeam() { this.enforceNoDirectCustomBanlistChanges(); - const seed = this.prng.seed; + const seed = this.prng.getSeed(); const ruleTable = this.dex.formats.getRuleTable(this.format); const pokemon: RandomTeamsTypes.RandomSet[] = []; diff --git a/data/random-battles/gen9cap/teams.ts b/data/random-battles/gen9cap/teams.ts index d17bf8b5105b..762aa582f818 100644 --- a/data/random-battles/gen9cap/teams.ts +++ b/data/random-battles/gen9cap/teams.ts @@ -185,7 +185,7 @@ export class RandomCAPTeams extends RandomTeams { randomTeam() { this.enforceNoDirectCustomBanlistChanges(); - const seed = this.prng.seed; + const seed = this.prng.getSeed(); const ruleTable = this.dex.formats.getRuleTable(this.format); const pokemon: RandomTeamsTypes.RandomSet[] = []; diff --git a/data/rulesets.ts b/data/rulesets.ts index 4196b578d0ec..e434c91e491c 100644 --- a/data/rulesets.ts +++ b/data/rulesets.ts @@ -2543,7 +2543,7 @@ export const Rulesets: import('../sim/dex-formats').FormatDataTable = { const spd = target.getStat('spd', false, true); const physical = Math.floor(Math.floor(Math.floor(Math.floor(2 * pokemon.level / 5 + 2) * 90 * atk) / def) / 50); const special = Math.floor(Math.floor(Math.floor(Math.floor(2 * pokemon.level / 5 + 2) * 90 * spa) / spd) / 50); - if (physical > special || (physical === special && this.random(2) === 0)) { + if (physical > special || (physical === special && this.randomChance(1, 2))) { move.category = 'Physical'; move.flags.contact = 1; } diff --git a/lib/utils.ts b/lib/utils.ts index b121d6738c51..eb516008c805 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -413,6 +413,21 @@ export function formatSQLArray(arr: unknown[], args?: unknown[]) { return [...'?'.repeat(arr.length)].join(', '); } +export function bufFromHex(hex: string) { + const buf = new Uint8Array(Math.ceil(hex.length / 2)); + bufWriteHex(buf, hex); + return buf; +} +export function bufWriteHex(buf: Uint8Array, hex: string, offset = 0) { + const size = Math.ceil(hex.length / 2); + for (let i = 0; i < size; i++) { + buf[offset + i] = parseInt(hex.slice(i * 2, i * 2 + 2).padEnd(2, '0'), 16); + } +} +export function bufReadHex(buf: Uint8Array, start = 0, end?: number) { + return [...buf.slice(start, end)].map(val => val.toString(16).padStart(2, '0')).join(''); +} + export class Multiset extends Map { get(key: T) { return super.get(key) ?? 0; @@ -436,5 +451,7 @@ export const Utils = { shuffle, deepClone, clearRequireCache, randomElement, forceWrap, splitFirst, stripHTML, visualize, getString, - escapeRegex, formatSQLArray, Multiset, + escapeRegex, formatSQLArray, + bufFromHex, bufReadHex, bufWriteHex, + Multiset, }; diff --git a/package-lock.json b/package-lock.json index 25a94b4dbe76..66bf0c509115 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "preact-render-to-string": "^5.1.19", "probe-image-size": "^7.2.3", "sockjs": "^0.3.21", - "source-map-support": "^0.5.21" + "source-map-support": "^0.5.21", + "ts-chacha20": "^1.2.0" }, "bin": { "pokemon-showdown": "pokemon-showdown" @@ -29,6 +30,7 @@ "@types/nodemailer": "^6.4.4", "@types/pg": "^8.6.5", "@types/sockjs": "^0.3.33", + "@types/sodium-native": "^2.3.9", "@typescript-eslint/eslint-plugin": "^5.8.0", "@typescript-eslint/parser": "^5.8.0", "eslint": "8.5.0", @@ -223,6 +225,16 @@ "@types/node": "*" } }, + "node_modules/@types/sodium-native": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@types/sodium-native/-/sodium-native-2.3.9.tgz", + "integrity": "sha512-jZIg5ltGH1okmnH3FrLQsgwjcjOVozMSHwSiEm1/LpMekhOMHbQqp21P4H24mizh1BjwI6Q8qmphmD/HJuAqWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.48.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.48.0.tgz", @@ -3555,6 +3567,12 @@ "node": ">=8.0" } }, + "node_modules/ts-chacha20": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ts-chacha20/-/ts-chacha20-1.2.0.tgz", + "integrity": "sha512-PTyPoWYHc2we8P2NTn5hpYG211popWbkjiw+k63xqjeMrx9pPtXSclz9F3fu0Tpr+vfR1xxcQFwsEkh1cXMLqw==", + "license": "MIT" + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -4014,6 +4032,15 @@ "@types/node": "*" } }, + "@types/sodium-native": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@types/sodium-native/-/sodium-native-2.3.9.tgz", + "integrity": "sha512-jZIg5ltGH1okmnH3FrLQsgwjcjOVozMSHwSiEm1/LpMekhOMHbQqp21P4H24mizh1BjwI6Q8qmphmD/HJuAqWg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@typescript-eslint/eslint-plugin": { "version": "5.48.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.48.0.tgz", @@ -6514,6 +6541,11 @@ "is-number": "^7.0.0" } }, + "ts-chacha20": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ts-chacha20/-/ts-chacha20-1.2.0.tgz", + "integrity": "sha512-PTyPoWYHc2we8P2NTn5hpYG211popWbkjiw+k63xqjeMrx9pPtXSclz9F3fu0Tpr+vfR1xxcQFwsEkh1cXMLqw==" + }, "ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", diff --git a/package.json b/package.json index 97613739752e..63d8435c70f9 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "preact-render-to-string": "^5.1.19", "probe-image-size": "^7.2.3", "sockjs": "^0.3.21", - "source-map-support": "^0.5.21" + "source-map-support": "^0.5.21", + "ts-chacha20": "^1.2.0" }, "optionalDependencies": { "better-sqlite3": "^7.6.2", @@ -72,6 +73,7 @@ "@types/nodemailer": "^6.4.4", "@types/pg": "^8.6.5", "@types/sockjs": "^0.3.33", + "@types/sodium-native": "^2.3.9", "@typescript-eslint/eslint-plugin": "^5.8.0", "@typescript-eslint/parser": "^5.8.0", "eslint": "8.5.0", diff --git a/sim/battle-stream.ts b/sim/battle-stream.ts index aa8c5ec27993..ed96d384d3b5 100644 --- a/sim/battle-stream.ts +++ b/sim/battle-stream.ts @@ -136,10 +136,12 @@ export class BattleStream extends Streams.ObjectReadWriteStream { this.battle!.inputLog.push(`>forcelose ${message}`); break; case 'reseed': - const seed = message ? message.split(',').map(Number) as PRNGSeed : null; + const seed = message ? message.split(',').map( + n => /[0-9]/.test(n.charAt(0)) ? Number(n) : n + ) as PRNGSeed : null; this.battle!.resetRNG(seed); // could go inside resetRNG, but this makes using it in `eval` slightly less buggy - this.battle!.inputLog.push(`>reseed ${this.battle!.prng.seed.join(',')}`); + this.battle!.inputLog.push(`>reseed ${this.battle!.prng.getSeed().join(',')}`); break; case 'tiebreak': this.battle!.tiebreak(); diff --git a/sim/battle.ts b/sim/battle.ts index fdb1fed720d0..d509d56f05d0 100644 --- a/sim/battle.ts +++ b/sim/battle.ts @@ -222,7 +222,7 @@ export class Battle { (format.playerCount > 2 || this.gameType === 'doubles') ? 2 : 1; this.prng = options.prng || new PRNG(options.seed || undefined); - this.prngSeed = this.prng.startingSeed.slice() as PRNGSeed; + this.prngSeed = this.prng.startingSeed; this.rated = options.rated || !!options.rated; this.reportExactHP = !!format.debug; this.reportPercentages = false; @@ -273,7 +273,7 @@ export class Battle { this.send = options.send || (() => {}); const inputOptions: {formatid: ID, seed: PRNGSeed, rated?: string | true} = { - formatid: options.formatid, seed: this.prng.seed, + formatid: options.formatid, seed: this.prngSeed, }; if (this.rated) inputOptions.rated = this.rated; if (typeof __version !== 'undefined') { @@ -340,7 +340,7 @@ export class Battle { } random(m?: number, n?: number) { - return this.prng.next(m, n); + return this.prng.random(m, n); } randomChance(numerator: number, denominator: number) { @@ -353,7 +353,7 @@ export class Battle { } /** Note that passing `undefined` resets to the starting seed, but `null` will roll a new seed */ - resetRNG(seed: PRNGSeed | null = this.prng.startingSeed) { + resetRNG(seed: PRNGSeed | null = this.prngSeed) { this.prng = new PRNG(seed); this.add('message', "The battle's RNG was reset."); } diff --git a/sim/pokemon.ts b/sim/pokemon.ts index bb954c86b7d5..dbffa6995071 100644 --- a/sim/pokemon.ts +++ b/sim/pokemon.ts @@ -317,7 +317,7 @@ export class Pokemon { set.level = this.battle.clampIntRange(set.adjustLevel || set.level || 100, 1, 9999); this.level = set.level; const genders: {[key: string]: GenderName} = {M: 'M', F: 'F', N: 'N'}; - this.gender = genders[set.gender] || this.species.gender || (this.battle.random() * 2 < 1 ? 'M' : 'F'); + this.gender = genders[set.gender] || this.species.gender || (this.battle.random(2) ? 'F' : 'M'); if (this.gender === 'N') this.gender = ''; this.happiness = typeof set.happiness === 'number' ? this.battle.clampIntRange(set.happiness, 0, 255) : 255; this.pokeball = this.set.pokeball || 'pokeball'; diff --git a/sim/prng.ts b/sim/prng.ts index ea02382522b5..503f75dff6e2 100644 --- a/sim/prng.ts +++ b/sim/prng.ts @@ -12,30 +12,51 @@ * @license MIT license */ +import {Chacha20} from 'ts-chacha20'; +import {Utils} from '../lib/utils'; + +export type PRNGSeed = SodiumRNGSeed | Gen5RNGSeed; +export type SodiumRNGSeed = ['sodium', string]; /** 64-bit big-endian [high -> low] int */ -export type PRNGSeed = [number, number, number, number]; +export type Gen5RNGSeed = [number, number, number, number]; /** - * A PRNG intended to emulate the on-cartridge PRNG for Gen 5 with a 64-bit - * initial seed. + * Low-level source of 32-bit random numbers. + */ +interface RNG { + getSeed(): PRNGSeed; + /** random 32-bit number */ + next(): number; +} + +/** + * High-level PRNG API, for getting random numbers. + * + * Chooses the RNG implementation based on the seed passed to the constructor. + * Seeds starting with 'sodium' use sodium. Other seeds use the Gen 5 RNG. + * If a seed isn't given, defaults to sodium. + * + * The actual randomness source is in this.rng. */ export class PRNG { - readonly initialSeed: PRNGSeed; - seed: PRNGSeed; + readonly startingSeed: PRNGSeed; + rng!: RNG; /** Creates a new source of randomness for the given seed. */ - constructor(seed: PRNGSeed | null = null) { + constructor(seed: PRNGSeed | null = null, initialSeed?: PRNGSeed) { if (!seed) seed = PRNG.generateSeed(); - this.initialSeed = seed.slice() as PRNGSeed; // make a copy - this.seed = seed.slice() as PRNGSeed; + this.startingSeed = initialSeed || [...seed]; // make a copy + this.setSeed(seed); } - /** - * Getter to the initial seed. - * - * This should be considered a hack and is only here for backwards compatibility. - */ - get startingSeed(): PRNGSeed { - return this.initialSeed; + setSeed(seed: PRNGSeed) { + if (seed[0] === 'sodium') { + this.rng = new SodiumRNG(seed); + } else { + this.rng = new Gen5RNG(seed as Gen5RNGSeed); + } + } + getSeed(): PRNGSeed { + return this.rng.getSeed(); } /** @@ -44,7 +65,7 @@ export class PRNG { * The new PRNG will have its initial seed set to the seed of the current instance. */ clone(): PRNG { - return new PRNG(this.seed); + return new PRNG(this.rng.getSeed(), this.startingSeed); } /** @@ -55,19 +76,18 @@ export class PRNG { * - random(m, n) returns an integer in [m, n) * m and n are converted to integers via Math.floor. If the result is NaN, they are ignored. */ - next(from?: number, to?: number): number { - this.seed = this.nextFrame(this.seed); // Advance the RNG - let result = (this.seed[0] << 16 >>> 0) + this.seed[1]; // Use the upper 32 bits + random(from?: number, to?: number): number { + const result = this.rng.next(); + if (from) from = Math.floor(from); if (to) to = Math.floor(to); if (from === undefined) { - result = result / 0x100000000; + return result / 2 ** 32; } else if (!to) { - result = Math.floor(result * from / 0x100000000); + return Math.floor(result * from / 2 ** 32); } else { - result = Math.floor(result * (to - from) / 0x100000000) + from; + return Math.floor(result * (to - from) / 2 ** 32) + from; } - return result; } /** @@ -81,7 +101,7 @@ export class PRNG { * The denominator must be a positive integer (`> 0`). */ randomChance(numerator: number, denominator: number): boolean { - return this.next(denominator) < numerator; + return this.random(denominator) < numerator; } /** @@ -101,7 +121,7 @@ export class PRNG { if (items.length === 0) { throw new RangeError(`Cannot sample an empty array`); } - const index = this.next(items.length); + const index = this.random(items.length); const item = items[index]; if (item === undefined && !Object.prototype.hasOwnProperty.call(items, index)) { throw new RangeError(`Cannot sample a sparse array`); @@ -117,7 +137,7 @@ export class PRNG { */ shuffle(items: T[], start = 0, end: number = items.length) { while (start < end - 1) { - const nextIndex = this.next(start, end); + const nextIndex = this.random(start, end); if (start !== nextIndex) { [items[start], items[nextIndex]] = [items[nextIndex], items[start]]; } @@ -125,13 +145,86 @@ export class PRNG { } } + static generateSeed(): SodiumRNGSeed { + return [ + 'sodium', + // 32 bits each, 128 bits total (16 bytes) + Math.trunc(Math.random() * 2 ** 32).toString(16).padStart(8, '0') + + Math.trunc(Math.random() * 2 ** 32).toString(16).padStart(8, '0') + + Math.trunc(Math.random() * 2 ** 32).toString(16).padStart(8, '0') + + Math.trunc(Math.random() * 2 ** 32).toString(16).padStart(8, '0'), + ]; + } +} + +/** + * This is a drop-in replacement for libsodium's randombytes_buf_deterministic, + * but it's implemented with ts-chacha20 instead, for a smaller dependency that + * doesn't use NodeJS native modules, for better portability. + */ +export class SodiumRNG implements RNG { + // nonce chosen to be compatible with libsodium's randombytes_buf_deterministic + // https://github.com/jedisct1/libsodium/blob/ce07d6c82c0e6c75031cf627913bf4f9d3f1e754/src/libsodium/randombytes/randombytes.c#L178 + static readonly NONCE = Uint8Array.from([..."LibsodiumDRG"].map(c => c.charCodeAt(0))); + seed!: Uint8Array; + /** Creates a new source of randomness for the given seed. */ + constructor(seed: SodiumRNGSeed) { + this.setSeed(seed); + } + + setSeed(seed: SodiumRNGSeed) { + // randombytes_buf_deterministic requires 32 bytes, but + // generateSeed generates 16 bytes, so the last 16 bytes will be 0 + // when starting out. This shouldn't cause any problems. + const seedBuf = new Uint8Array(32); + Utils.bufWriteHex(seedBuf, seed[1].padEnd(64, '0')); + this.seed = seedBuf; + } + getSeed(): SodiumRNGSeed { + return ['sodium', Utils.bufReadHex(this.seed)]; + } + + next() { + const zeroBuf = new Uint8Array(36); + // tested to do the exact same thing as + // sodium.randombytes_buf_deterministic(buf, this.seed); + const buf = new Chacha20(this.seed, SodiumRNG.NONCE).encrypt(zeroBuf); + + // use the first 32 bytes for the next seed, and the next 4 bytes for the output + this.seed = buf.slice(0, 32); + // reading big-endian + return buf.slice(32, 36).reduce((a, b) => a * 256 + b); + // alternative, probably slower (TODO: benchmark) + // return parseInt(Utils.bufReadHex(buf, 32, 36), 16); + } +} + +/** + * A PRNG intended to emulate the on-cartridge PRNG for Gen 5 with a 64-bit + * initial seed. + */ +export class Gen5RNG implements RNG { + seed: Gen5RNGSeed; + /** Creates a new source of randomness for the given seed. */ + constructor(seed: Gen5RNGSeed | null = null) { + this.seed = [...seed || Gen5RNG.generateSeed()]; + } + + getSeed() { + return this.seed; + } + + next(): number { + this.seed = this.nextFrame(this.seed); // Advance the RNG + return (this.seed[0] << 16 >>> 0) + this.seed[1]; // Use the upper 32 bits + } + /** * Calculates `a * b + c` (with 64-bit 2's complement integers) - * - * If you've done long multiplication, this is the same thing. */ - multiplyAdd(a: PRNGSeed, b: PRNGSeed, c: PRNGSeed) { - const out: PRNGSeed = [0, 0, 0, 0]; + multiplyAdd(a: Gen5RNGSeed, b: Gen5RNGSeed, c: Gen5RNGSeed) { + // If you've done long multiplication, this is the same thing. + const out: Gen5RNGSeed = [0, 0, 0, 0]; let carry = 0; for (let outIndex = 3; outIndex >= 0; outIndex--) { @@ -160,39 +253,48 @@ export class PRNG { * m = 2^64 * ```` */ - nextFrame(seed: PRNGSeed, framesToAdvance = 1): PRNGSeed { - const a: PRNGSeed = [0x5D58, 0x8B65, 0x6C07, 0x8965]; - const c: PRNGSeed = [0, 0, 0x26, 0x9EC3]; + nextFrame(seed: Gen5RNGSeed, framesToAdvance = 1): Gen5RNGSeed { + const a: Gen5RNGSeed = [0x5D58, 0x8B65, 0x6C07, 0x8965]; + const c: Gen5RNGSeed = [0, 0, 0x26, 0x9EC3]; for (let i = 0; i < framesToAdvance; i++) { + // seed = seed * a + c seed = this.multiplyAdd(seed, a, c); } return seed; } - static generateSeed() { + static generateSeed(): Gen5RNGSeed { return [ - Math.floor(Math.random() * 0x10000), - Math.floor(Math.random() * 0x10000), - Math.floor(Math.random() * 0x10000), - Math.floor(Math.random() * 0x10000), - ] as PRNGSeed; + Math.trunc(Math.random() * 2 ** 16), + Math.trunc(Math.random() * 2 ** 16), + Math.trunc(Math.random() * 2 ** 16), + Math.trunc(Math.random() * 2 ** 16), + ]; } } -// The following commented-out function is designed to emulate the on-cartridge +// The following commented-out class is designed to emulate the on-cartridge // PRNG for Gens 3 and 4, as described in // https://www.smogon.com/ingame/rng/pid_iv_creation#pokemon_random_number_generator // This RNG uses a 32-bit initial seed // m and n are converted to integers via Math.floor. If the result is NaN, they // are ignored. /* -random(m: number, n: number) { - this.seed = (this.seed * 0x41C64E6D + 0x6073) >>> 0; // truncate the result to the last 32 bits - let result = this.seed >>> 16; // the first 16 bits of the seed are the random value - m = Math.floor(m) - n = Math.floor(n) - return (m ? (n ? (result % (n - m)) + m : result % m) : result / 0x10000) +export type Gen3RNGSeed = ['gen3', number]; +export class Gen3RNG implements RNG { + seed: number; + constructor(seed: Gen3RNGSeed | null = null) { + this.seed = seed ? seed[1] : Math.trunc(Math.random() * 2 ** 32); + } + getSeed() { + return ['gen3', this.seed]; + } + next(): number { + this.seed = this.seed * 0x41C64E6D + 0x6073) >>> 0; // truncate the result to the last 32 bits + const val = this.seed >>> 16; // the first 16 bits of the seed are the random value + return val << 16 >>> 0; // PRNG#random expects a 32-bit number and will divide accordingly + } } */ diff --git a/sim/state.ts b/sim/state.ts index 279b1598772f..7a96d6024385 100644 --- a/sim/state.ts +++ b/sim/state.ts @@ -66,7 +66,7 @@ export const State = new class { for (const [i, side] of battle.sides.entries()) { state.sides[i] = this.serializeSide(side); } - state.prng = battle.prng.seed; + state.prng = battle.prng.getSeed(); state.hints = Array.from(battle.hints); // We treat log specially because we only set it back on Battle after everything // else has been deserialized to avoid anything accidentally `add`-ing to it. diff --git a/sim/tools/exhaustive-runner.ts b/sim/tools/exhaustive-runner.ts index 55019c0890ad..161f7c8d7fe1 100644 --- a/sim/tools/exhaustive-runner.ts +++ b/sim/tools/exhaustive-runner.ts @@ -73,7 +73,7 @@ export class ExhaustiveRunner { async run() { const dex = Dex.forFormat(this.format); - const seed = this.prng.seed; + const seed = this.prng.getSeed(); const pools = this.createPools(dex); const createAI = (s: ObjectReadWriteStream, o: AIOptions) => new CoordinatedPlayerAI(s, o, pools); const generator = new TeamGenerator(dex, this.prng, pools, ExhaustiveRunner.getSignatures(dex, pools)); @@ -216,13 +216,13 @@ class TeamGenerator { const team: PokemonSet[] = []; for (const pokemon of this.pools.pokemon.next(6)) { const species = this.dex.species.get(pokemon); - const randomEVs = () => this.prng.next(253); - const randomIVs = () => this.prng.next(32); + const randomEVs = () => this.prng.random(253); + const randomIVs = () => this.prng.random(32); let item; const moves = []; const combos = this.signatures.get(species.id); - if (combos && this.prng.next() > TeamGenerator.COMBO) { + if (combos && this.prng.random() > TeamGenerator.COMBO) { const combo = this.prng.sample(combos); item = combo.item; if (combo.move) moves.push(combo.move); @@ -254,8 +254,8 @@ class TeamGenerator { spe: randomIVs(), }, nature: this.prng.sample(this.natures), - level: this.prng.next(50, 100), - happiness: this.prng.next(256), + level: this.prng.random(50, 100), + happiness: this.prng.random(256), shiny: this.prng.randomChance(1, 1024), }); } @@ -309,7 +309,7 @@ class Pool { private shuffle(arr: T[]): T[] { for (let i = arr.length - 1; i > 0; i--) { - const j = Math.floor(this.prng.next() * (i + 1)); + const j = this.prng.random(i + 1); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; @@ -383,7 +383,7 @@ class Pool { this.filler = this.possible.slice(); length = this.filler.length; } - const index = this.prng.next(length); + const index = this.prng.random(length); const element = this.filler![index]; this.filler![index] = this.filler![length - 1]; this.filler!.pop(); diff --git a/sim/tools/multi-random-runner.ts b/sim/tools/multi-random-runner.ts index a78440321818..70b373a4ed2d 100644 --- a/sim/tools/multi-random-runner.ts +++ b/sim/tools/multi-random-runner.ts @@ -71,7 +71,7 @@ export class MultiRandomRunner { games = []; } - const seed = this.prng.seed; + const seed = this.prng.getSeed(); const game = new Runner({format, ...this.options}).run().catch(err => { failures++; console.error( diff --git a/sim/tools/random-player-ai.ts b/sim/tools/random-player-ai.ts index 7c12a3dafb50..fde8a91e372a 100644 --- a/sim/tools/random-player-ai.ts +++ b/sim/tools/random-player-ai.ts @@ -79,7 +79,7 @@ export class RandomPlayerAI extends BattlePlayer { canTerastallize = canTerastallize && !!active.canTerastallize; // Determine whether we should change form if we do end up switching - const change = (canMegaEvo || canUltraBurst || canDynamax) && this.prng.next() < this.mega; + const change = (canMegaEvo || canUltraBurst || canDynamax) && this.prng.random() < this.mega; // If we've already dynamaxed or if we're planning on potentially dynamaxing // we need to use the maxMoves instead of our regular moves @@ -120,14 +120,14 @@ export class RandomPlayerAI extends BattlePlayer { // NOTE: We don't generate all possible targeting combinations. if (request.active.length > 1) { if ([`normal`, `any`, `adjacentFoe`].includes(m.target)) { - move += ` ${1 + Math.floor(this.prng.next() * 2)}`; + move += ` ${1 + this.prng.random(2)}`; } if (m.target === `adjacentAlly`) { move += ` -${(i ^ 1) + 1}`; } if (m.target === `adjacentAllyOrSelf`) { if (hasAlly) { - move += ` -${1 + Math.floor(this.prng.next() * 2)}`; + move += ` -${1 + this.prng.random(2)}`; } else { move += ` -${i + 1}`; } @@ -148,7 +148,7 @@ export class RandomPlayerAI extends BattlePlayer { )); const switches = active.trapped ? [] : canSwitch; - if (switches.length && (!moves.length || this.prng.next() > this.move)) { + if (switches.length && (!moves.length || this.prng.random() > this.move)) { const target = this.chooseSwitch( active, canSwitch.map(slot => ({slot, pokemon: pokemon[slot - 1]})) diff --git a/sim/tools/runner.ts b/sim/tools/runner.ts index eb6d63ce3234..11a86680739b 100644 --- a/sim/tools/runner.ts +++ b/sim/tools/runner.ts @@ -86,7 +86,7 @@ export class Runner { private async runGame(format: string, battleStream: RawBattleStream | DualStream) { // @ts-ignore - DualStream implements everything relevant from BattleStream. const streams = BattleStreams.getPlayerStreams(battleStream); - const spec = {formatid: format, seed: this.prng.seed}; + const spec = {formatid: format, seed: this.prng.getSeed()}; const is4P = Dex.formats.get(format).playerCount > 2; const p1spec = this.getPlayerSpec("Bot 1", this.p1options); const p2spec = this.getPlayerSpec("Bot 2", this.p2options); @@ -140,10 +140,10 @@ export class Runner { // NOTE: advances this.prng's seed by 4. private newSeed(): PRNGSeed { return [ - Math.floor(this.prng.next() * 0x10000), - Math.floor(this.prng.next() * 0x10000), - Math.floor(this.prng.next() * 0x10000), - Math.floor(this.prng.next() * 0x10000), + this.prng.random(2 ** 16), + this.prng.random(2 ** 16), + this.prng.random(2 ** 16), + this.prng.random(2 ** 16), ]; } diff --git a/test/sim/misc/prng.js b/test/sim/misc/prng.js index c000c9547f56..3a67637c8f1c 100644 --- a/test/sim/misc/prng.js +++ b/test/sim/misc/prng.js @@ -3,9 +3,24 @@ const PRNG = require('../../../dist/sim/prng').PRNG; const assert = require('../../assert'); -const testSeed = [1, 2, 3, 4]; +const testSeed = ['sodium', '00000001000000020000000300000004']; describe(`PRNG`, function () { + it("should always generate the same results off the same seed", function () { + const results = []; + const testAgainst = new PRNG(testSeed); + for (let i = 0; i < 100; i++) { + results.push(testAgainst.random()); + } + for (let i = 0; i < 10; i++) { + const cur = new PRNG(testSeed); + for (let j = 0; j < results.length; j++) { + const n = cur.random(); + assert(results[j] === n, `generation ${j} for seed ${testSeed} did not match (expected: ${results[j]}, got ${n})`); + } + } + }); + describe(`randomChance(numerator=0, denominator=1)`, function () { it(`should always return false`, function () { const prng = new PRNG(testSeed); @@ -41,12 +56,12 @@ describe(`PRNG`, function () { } assert.bounded(trueCount, [45, 55]); }); - it(`should be identical to (next(2) == 0)`, function () { + it(`should be identical to (random(2) == 0)`, function () { // This invariant is important for battle logs. const coinPRNG = new PRNG(testSeed); const numberPRNG = new PRNG(testSeed); for (let i = 0; i < 10; ++i) { - assert.equal(numberPRNG.next(2) === 0, coinPRNG.randomChance(1, 2)); + assert.equal(numberPRNG.random(2) === 0, coinPRNG.randomChance(1, 2)); } }); }); @@ -61,12 +76,12 @@ describe(`PRNG`, function () { } assert.bounded(trueCount, [80, 90]); }); - it(`should be identical to (next(256) < 217)`, function () { + it(`should be identical to (random(256) < 217)`, function () { // This invariant is important for battle logs. const coinPRNG = new PRNG(testSeed); const numberPRNG = new PRNG(testSeed); for (let i = 0; i < 10; ++i) { - assert.equal(numberPRNG.next(256) < 217, coinPRNG.randomChance(217, 256)); + assert.equal(numberPRNG.random(256) < 217, coinPRNG.randomChance(217, 256)); } }); }); @@ -129,13 +144,13 @@ describe(`PRNG`, function () { assert.bounded(occurences.x, [63, 71]); assert.bounded(occurences.y, [29, 37]); }); - it(`should be identical to array[next(array.length)]`, function () { + it(`should be identical to array[random(array.length)]`, function () { // This invariant is important for battle logs. const items = [{}, {}, {}, {}, {}, {}, {}, {}]; const samplePRNG = new PRNG(testSeed); const randomIntegerPRNG = new PRNG(testSeed); for (let i = 0; i < 10; ++i) { - assert.equal(items[randomIntegerPRNG.next(items.length)], samplePRNG.sample(items)); + assert.equal(items[randomIntegerPRNG.random(items.length)], samplePRNG.sample(items)); } }); });