[Bug] Fix eggs having exploitable RNG (#3913)

* [Bug] Fix eggs having exploitable RNG

* Fix Wind Rider test having random chance to fail

* Revert egg's ID back to its own unseeded generation

* Remove change from wind rider test

---------

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
Co-authored-by: Mumble <171087428+frutescens@users.noreply.github.com>
This commit is contained in:
PigeonBar 2024-09-02 22:18:18 -04:00 committed by GitHub
parent deb4e9dd24
commit 587360c8da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 161 additions and 65 deletions

View File

@ -974,6 +974,7 @@ export default class BattleScene extends SceneBase {
this.setSeed(Overrides.SEED_OVERRIDE || Utils.randomString(24));
console.log("Seed:", this.seed);
this.resetSeed(); // Properly resets RNG after saving and quitting a session
this.disableMenu = false;

View File

@ -139,6 +139,7 @@ export class Egg {
////
constructor(eggOptions?: IEggOptions) {
const generateEggProperties = (eggOptions?: IEggOptions) => {
//if (eggOptions.tier && eggOptions.species) throw Error("Error egg can't have species and tier as option. only choose one of them.")
this._sourceType = eggOptions?.sourceType!; // TODO: is this bang correct?
@ -180,6 +181,16 @@ export class Egg {
this.increasePullStatistic(eggOptions.scene!); // TODO: is this bang correct?
this.addEggToGameData(eggOptions.scene!); // TODO: is this bang correct?
}
};
if (eggOptions?.scene) {
const seedOverride = Utils.randomString(24);
eggOptions?.scene.executeWithSeedOffset(() => {
generateEggProperties(eggOptions);
}, 0, seedOverride);
} else { // For legacy eggs without scene
generateEggProperties(eggOptions);
}
}
////
@ -200,6 +211,9 @@ export class Egg {
// Generates a PlayerPokemon from an egg
public generatePlayerPokemon(scene: BattleScene): PlayerPokemon {
let ret: PlayerPokemon;
const generatePlayerPokemonHelper = (scene: BattleScene) => {
// Legacy egg wants to hatch. Generate missing properties
if (!this._species) {
this._isShiny = this.rollShiny();
@ -222,7 +236,7 @@ export class Egg {
}
// This function has way to many optional parameters
const ret: PlayerPokemon = scene.addPlayerPokemon(pokemonSpecies, 1, abilityIndex, undefined, undefined, false);
ret = scene.addPlayerPokemon(pokemonSpecies, 1, abilityIndex, undefined, undefined, false);
ret.shiny = this._isShiny;
ret.variant = this._variantTier;
@ -231,6 +245,12 @@ export class Egg {
for (let s = 0; s < ret.ivs.length; s++) {
ret.ivs[s] = Math.max(ret.ivs[s], secondaryIvs[s]);
}
};
ret = ret!; // Tell TS compiler it's defined now
scene.executeWithSeedOffset(() => {
generatePlayerPokemonHelper(scene);
}, this._id, EGG_SEED.toString());
return ret;
}

View File

@ -1,5 +1,5 @@
import { LoadingScene } from "#app/loading-scene";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import GameManager from "./utils/gameManager";
describe("BattleScene", () => {
@ -24,4 +24,12 @@ describe("BattleScene", () => {
// `BattleScene.create()` is called during the `new GameManager()` call
expect(game.scene.scene.remove).toHaveBeenCalledWith(LoadingScene.KEY);
});
it("should also reset RNG on reset", () => {
vi.spyOn(game.scene, "resetSeed");
game.scene.reset();
expect(game.scene.resetSeed).toHaveBeenCalled();
});
});

View File

@ -12,6 +12,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite
describe("Egg Generation Tests", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
const EGG_HATCH_COUNT: integer = 1000;
beforeAll(() => {
phaserGame = new Phaser.Game({
@ -47,14 +48,21 @@ describe("Egg Generation Tests", () => {
expect(result).toBe(expectedSpecies);
});
it("should hatch an Arceus. Set from legendary gacha", async () => {
it("should hatch an Arceus around half the time. Set from legendary gacha", async () => {
const scene = game.scene;
const timestamp = new Date(2024, 6, 10, 15, 0, 0, 0).getTime();
const expectedSpecies = Species.ARCEUS;
let gachaSpeciesCount = 0;
for (let i = 0; i < EGG_HATCH_COUNT; i++) {
const result = new Egg({ scene, timestamp, sourceType: EggSourceType.GACHA_LEGENDARY, tier: EggTier.MASTER }).generatePlayerPokemon(scene).species.speciesId;
if (result === expectedSpecies) {
gachaSpeciesCount++;
}
}
expect(result).toBe(expectedSpecies);
expect(gachaSpeciesCount).toBeGreaterThan(0.4 * EGG_HATCH_COUNT);
expect(gachaSpeciesCount).toBeLessThan(0.6 * EGG_HATCH_COUNT);
});
it("should hatch an Arceus. Set from species", () => {
const scene = game.scene;
@ -156,7 +164,7 @@ describe("Egg Generation Tests", () => {
const scene = game.scene;
const eggMoveIndex = new Egg({ scene }).eggMoveIndex;
const result = eggMoveIndex && eggMoveIndex >= 0 && eggMoveIndex <= 3;
const result = !Utils.isNullOrUndefined(eggMoveIndex) && eggMoveIndex >= 0 && eggMoveIndex <= 3;
expect(result).toBe(true);
});
@ -309,4 +317,63 @@ describe("Egg Generation Tests", () => {
expect(egg.variantTier).toBe(VariantTier.EPIC);
});
it("should generate egg moves, species, shinyness, and ability unpredictably for the egg gacha", () => {
const scene = game.scene;
scene.setSeed("ABCDEFGHIJKLMNOPQRSTUVWXYZ");
scene.resetSeed();
const firstEgg = new Egg({scene, sourceType: EggSourceType.GACHA_SHINY, tier: EggTier.COMMON});
const firstHatch = firstEgg.generatePlayerPokemon(scene);
let diffEggMove = false;
let diffSpecies = false;
let diffShiny = false;
let diffAbility = false;
for (let i = 0; i < EGG_HATCH_COUNT; i++) {
scene.gameData.unlockPity[EggTier.COMMON] = 0;
scene.setSeed("ABCDEFGHIJKLMNOPQRSTUVWXYZ");
scene.resetSeed(); // Make sure that eggs are unpredictable even if using same seed
const newEgg = new Egg({scene, sourceType: EggSourceType.GACHA_SHINY, tier: EggTier.COMMON});
const newHatch = newEgg.generatePlayerPokemon(scene);
diffEggMove = diffEggMove || (newEgg.eggMoveIndex !== firstEgg.eggMoveIndex);
diffSpecies = diffSpecies || (newHatch.species.speciesId !== firstHatch.species.speciesId);
diffShiny = diffShiny || (newHatch.shiny !== firstHatch.shiny);
diffAbility = diffAbility || (newHatch.abilityIndex !== firstHatch.abilityIndex);
}
expect(diffEggMove).toBe(true);
expect(diffSpecies).toBe(true);
expect(diffShiny).toBe(true);
expect(diffAbility).toBe(true);
});
it("should generate egg moves, shinyness, and ability unpredictably for species eggs", () => {
const scene = game.scene;
scene.setSeed("ABCDEFGHIJKLMNOPQRSTUVWXYZ");
scene.resetSeed();
const firstEgg = new Egg({scene, species: Species.BULBASAUR});
const firstHatch = firstEgg.generatePlayerPokemon(scene);
let diffEggMove = false;
let diffSpecies = false;
let diffShiny = false;
let diffAbility = false;
for (let i = 0; i < EGG_HATCH_COUNT; i++) {
scene.setSeed("ABCDEFGHIJKLMNOPQRSTUVWXYZ");
scene.resetSeed(); // Make sure that eggs are unpredictable even if using same seed
const newEgg = new Egg({scene, species: Species.BULBASAUR});
const newHatch = newEgg.generatePlayerPokemon(scene);
diffEggMove = diffEggMove || (newEgg.eggMoveIndex !== firstEgg.eggMoveIndex);
diffSpecies = diffSpecies || (newHatch.species.speciesId !== firstHatch.species.speciesId);
diffShiny = diffShiny || (newHatch.shiny !== firstHatch.shiny);
diffAbility = diffAbility || (newHatch.abilityIndex !== firstHatch.abilityIndex);
}
expect(diffEggMove).toBe(true);
expect(diffSpecies).toBe(false);
expect(diffShiny).toBe(true);
expect(diffAbility).toBe(true);
});
});