[Bug] Fix reloads causing RNG inconsistencies, Moody and Acupressure use seeded RNG now (#3952)

* [Bug] Fix reloads causing RNG inconsistencies

* Minor revisions

* Allow reload helper to directly access getSessionSaveData()

* Changed Moody and Acupressure to use seeded RNG

* Fix broken unit test
This commit is contained in:
PigeonBar 2024-09-03 09:14:45 -04:00 committed by GitHub
parent 232cd2c91a
commit 3a6146935c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 222 additions and 6 deletions

View File

@ -3475,12 +3475,12 @@ export class MoodyAbAttr extends PostTurnAbAttr {
if (!simulated) {
if (canRaise.length > 0) {
const raisedStat = Utils.randSeedItem(canRaise);
const raisedStat = canRaise[pokemon.randSeedInt(canRaise.length)];
canLower = canRaise.filter(s => s !== raisedStat);
pokemon.scene.unshiftPhase(new StatStageChangePhase(pokemon.scene, pokemon.getBattlerIndex(), true, [ raisedStat ], 2));
}
if (canLower.length > 0) {
const loweredStat = Utils.randSeedItem(canLower);
const loweredStat = canLower[pokemon.randSeedInt(canLower.length)];
pokemon.scene.unshiftPhase(new StatStageChangePhase(pokemon.scene, pokemon.getBattlerIndex(), true, [ loweredStat ], -1));
}
}

View File

@ -2675,7 +2675,7 @@ export class AcupressureStatStageChangeAttr extends MoveEffectAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean | Promise<boolean> {
const randStats = BATTLE_STATS.filter(s => target.getStatStage(s) < 6);
if (randStats.length > 0) {
const boostStat = [randStats[Utils.randInt(randStats.length)]];
const boostStat = [randStats[user.randSeedInt(randStats.length)]];
user.scene.unshiftPhase(new StatStageChangePhase(user.scene, target.getBattlerIndex(), this.selfTarget, boostStat, 2));
return true;
}

View File

@ -161,9 +161,11 @@ export class EncounterPhase extends BattlePhase {
return this.scene.reset(true);
}
this.doEncounter();
this.scene.resetSeed();
});
} else {
this.doEncounter();
this.scene.resetSeed();
}
});
});

View File

@ -946,7 +946,7 @@ export class GameData {
return ret;
}
private getSessionSaveData(scene: BattleScene): SessionSaveData {
public getSessionSaveData(scene: BattleScene): SessionSaveData {
return {
seed: scene.seed,
playTime: scene.sessionPlayTime,

137
src/test/reload.test.ts Normal file
View File

@ -0,0 +1,137 @@
import { Species } from "#app/enums/species";
import { GameModes } from "#app/game-mode";
import GameManager from "#test/utils/gameManager";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { SPLASH_ONLY } from "./utils/testUtils";
import { Moves } from "#app/enums/moves";
import { Biome } from "#app/enums/biome";
describe("Reload", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
});
it("should not have RNG inconsistencies in a Classic run", async () => {
await game.classicMode.startBattle();
const preReloadRngState = Phaser.Math.RND.state();
await game.reload.reloadSession();
const postReloadRngState = Phaser.Math.RND.state();
expect(preReloadRngState).toBe(postReloadRngState);
}, 20000);
it("should not have RNG inconsistencies after a biome switch", async () => {
game.override
.startingWave(10)
.startingBiome(Biome.CAVE) // Will lead to biomes with randomly generated weather
.battleType("single")
.startingLevel(100)
.enemyLevel(1000)
.disableTrainerWaves()
.moveset([Moves.KOWTOW_CLEAVE])
.enemyMoveset(SPLASH_ONLY);
await game.dailyMode.startBattle();
// Transition from Daily Run Wave 10 to Wave 11 in order to trigger biome switch
game.move.select(Moves.KOWTOW_CLEAVE);
await game.phaseInterceptor.to("DamagePhase");
await game.doKillOpponents();
await game.toNextWave();
expect(game.phaseInterceptor.log).toContain("NewBiomeEncounterPhase");
const preReloadRngState = Phaser.Math.RND.state();
await game.reload.reloadSession();
const postReloadRngState = Phaser.Math.RND.state();
expect(preReloadRngState).toBe(postReloadRngState);
}, 20000);
it("should not have RNG inconsistencies at a Daily run wild Pokemon fight", async () => {
await game.dailyMode.startBattle();
const preReloadRngState = Phaser.Math.RND.state();
await game.reload.reloadSession();
const postReloadRngState = Phaser.Math.RND.state();
expect(preReloadRngState).toBe(postReloadRngState);
}, 20000);
it("should not have RNG inconsistencies at a Daily run double battle", async () => {
game.override
.battleType("double");
await game.dailyMode.startBattle();
const preReloadRngState = Phaser.Math.RND.state();
await game.reload.reloadSession();
const postReloadRngState = Phaser.Math.RND.state();
expect(preReloadRngState).toBe(postReloadRngState);
}, 20000);
it("should not have RNG inconsistencies at a Daily run Gym Leader fight", async () => {
game.override
.battleType("single")
.startingWave(40);
await game.dailyMode.startBattle();
const preReloadRngState = Phaser.Math.RND.state();
await game.reload.reloadSession();
const postReloadRngState = Phaser.Math.RND.state();
expect(preReloadRngState).toBe(postReloadRngState);
}, 20000);
it("should not have RNG inconsistencies at a Daily run regular trainer fight", async () => {
game.override
.battleType("single")
.startingWave(45);
await game.dailyMode.startBattle();
const preReloadRngState = Phaser.Math.RND.state();
await game.reload.reloadSession();
const postReloadRngState = Phaser.Math.RND.state();
expect(preReloadRngState).toBe(postReloadRngState);
}, 20000);
it("should not have RNG inconsistencies at a Daily run wave 50 Boss fight", async () => {
game.override
.battleType("single")
.startingWave(50);
await game.runToFinalBossEncounter([Species.BULBASAUR], GameModes.DAILY);
const preReloadRngState = Phaser.Math.RND.state();
await game.reload.reloadSession();
const postReloadRngState = Phaser.Math.RND.state();
expect(preReloadRngState).toBe(postReloadRngState);
}, 20000);
});

View File

@ -45,6 +45,8 @@ import { ChallengeModeHelper } from "./helpers/challengeModeHelper";
import { MoveHelper } from "./helpers/moveHelper";
import { OverridesHelper } from "./helpers/overridesHelper";
import { SettingsHelper } from "./helpers/settingsHelper";
import { ReloadHelper } from "./helpers/reloadHelper";
import { CheckSwitchPhase } from "#app/phases/check-switch-phase";
/**
* Class to manage the game state and transitions between phases.
@ -61,6 +63,7 @@ export default class GameManager {
public readonly dailyMode: DailyModeHelper;
public readonly challengeMode: ChallengeModeHelper;
public readonly settings: SettingsHelper;
public readonly reload: ReloadHelper;
/**
* Creates an instance of GameManager.
@ -82,6 +85,7 @@ export default class GameManager {
this.dailyMode = new DailyModeHelper(this);
this.challengeMode = new ChallengeModeHelper(this);
this.settings = new SettingsHelper(this);
this.reload = new ReloadHelper(this);
}
/**
@ -231,12 +235,12 @@ export default class GameManager {
this.onNextPrompt("SelectModifierPhase", Mode.MODIFIER_SELECT, () => {
const handler = this.scene.ui.getHandler() as ModifierSelectUiHandler;
handler.processInput(Button.CANCEL);
}, () => this.isCurrentPhase(CommandPhase) || this.isCurrentPhase(NewBattlePhase), true);
}, () => this.isCurrentPhase(CommandPhase) || this.isCurrentPhase(NewBattlePhase) || this.isCurrentPhase(CheckSwitchPhase), true);
this.onNextPrompt("SelectModifierPhase", Mode.CONFIRM, () => {
const handler = this.scene.ui.getHandler() as ModifierSelectUiHandler;
handler.processInput(Button.ACTION);
}, () => this.isCurrentPhase(CommandPhase) || this.isCurrentPhase(NewBattlePhase));
}, () => this.isCurrentPhase(CommandPhase) || this.isCurrentPhase(NewBattlePhase) || this.isCurrentPhase(CheckSwitchPhase));
}
forceOpponentToSwitch() {

View File

@ -0,0 +1,53 @@
import { GameManagerHelper } from "./gameManagerHelper";
import { TitlePhase } from "#app/phases/title-phase";
import { Mode } from "#app/ui/ui";
import { vi } from "vitest";
import { BattleStyle } from "#app/enums/battle-style";
import { CommandPhase } from "#app/phases/command-phase";
import { TurnInitPhase } from "#app/phases/turn-init-phase";
/**
* Helper to allow reloading sessions in unit tests.
*/
export class ReloadHelper extends GameManagerHelper {
/**
* Simulate reloading the session from the title screen, until reaching the
* beginning of the first turn (equivalent to running `startBattle()`) for
* the reloaded session.
*/
async reloadSession() : Promise<void> {
const scene = this.game.scene;
const sessionData = scene.gameData.getSessionSaveData(scene);
const titlePhase = new TitlePhase(scene);
scene.clearPhaseQueue();
// Set the last saved session to the desired session data
vi.spyOn(scene.gameData, "getSession").mockReturnValue(
new Promise((resolve, reject) => {
resolve(sessionData);
})
);
scene.unshiftPhase(titlePhase);
this.game.endPhase(); // End the currently ongoing battle
titlePhase.loadSaveSlot(-1); // Load the desired session data
this.game.phaseInterceptor.shift(); // Loading the save slot also ended TitlePhase, clean it up
// Run through prompts for switching Pokemon, copied from classicModeHelper.ts
if (this.game.scene.battleStyle === BattleStyle.SWITCH) {
this.game.onNextPrompt("CheckSwitchPhase", Mode.CONFIRM, () => {
this.game.setMode(Mode.MESSAGE);
this.game.endPhase();
}, () => this.game.isCurrentPhase(CommandPhase) || this.game.isCurrentPhase(TurnInitPhase));
this.game.onNextPrompt("CheckSwitchPhase", Mode.CONFIRM, () => {
this.game.setMode(Mode.MESSAGE);
this.game.endPhase();
}, () => this.game.isCurrentPhase(CommandPhase) || this.game.isCurrentPhase(TurnInitPhase));
}
await this.game.phaseInterceptor.to(CommandPhase);
console.log("==================[New Turn]==================");
}
}

View File

@ -11,12 +11,14 @@ import { EndEvolutionPhase } from "#app/phases/end-evolution-phase";
import { EnemyCommandPhase } from "#app/phases/enemy-command-phase";
import { EvolutionPhase } from "#app/phases/evolution-phase";
import { FaintPhase } from "#app/phases/faint-phase";
import { LevelCapPhase } from "#app/phases/level-cap-phase";
import { LoginPhase } from "#app/phases/login-phase";
import { MessagePhase } from "#app/phases/message-phase";
import { MoveEffectPhase } from "#app/phases/move-effect-phase";
import { MoveEndPhase } from "#app/phases/move-end-phase";
import { MovePhase } from "#app/phases/move-phase";
import { NewBattlePhase } from "#app/phases/new-battle-phase";
import { NewBiomeEncounterPhase } from "#app/phases/new-biome-encounter-phase";
import { NextEncounterPhase } from "#app/phases/next-encounter-phase";
import { PostSummonPhase } from "#app/phases/post-summon-phase";
import { QuietFormChangePhase } from "#app/phases/quiet-form-change-phase";
@ -62,6 +64,7 @@ export default class PhaseInterceptor {
[TitlePhase, this.startPhase],
[SelectGenderPhase, this.startPhase],
[EncounterPhase, this.startPhase],
[NewBiomeEncounterPhase, this.startPhase],
[SelectStarterPhase, this.startPhase],
[PostSummonPhase, this.startPhase],
[SummonPhase, this.startPhase],
@ -96,6 +99,7 @@ export default class PhaseInterceptor {
[PartyHealPhase, this.startPhase],
[EvolutionPhase, this.startPhase],
[EndEvolutionPhase, this.startPhase],
[LevelCapPhase, this.startPhase],
];
private endBySetMode = [
@ -237,6 +241,22 @@ export default class PhaseInterceptor {
this.scene.shiftPhase();
}
/**
* Remove the current phase from the phase interceptor.
*
* Do not call this unless absolutely necessary. This function is intended
* for cleaning up the phase interceptor when, for whatever reason, a phase
* is manually ended without using the phase interceptor.
*
* @param shouldRun Whether or not the current scene should also be run.
*/
shift(shouldRun: boolean = false) : void {
this.onHold.shift();
if (shouldRun) {
this.scene.shiftPhase();
}
}
/**
* Method to initialize phases and their corresponding methods.
*/