diff --git a/src/phases/encounter-phase.ts b/src/phases/encounter-phase.ts index 07bfd72a8bf..732ada5aaf1 100644 --- a/src/phases/encounter-phase.ts +++ b/src/phases/encounter-phase.ts @@ -159,9 +159,11 @@ export class EncounterPhase extends BattlePhase { return this.scene.reset(true); } this.doEncounter(); + this.scene.resetSeed(); }); } else { this.doEncounter(); + this.scene.resetSeed(); } }); }); diff --git a/src/test/reload.test.ts b/src/test/reload.test.ts new file mode 100644 index 00000000000..f15662a41f8 --- /dev/null +++ b/src/test/reload.test.ts @@ -0,0 +1,137 @@ +import { Species } from "#app/enums/species.js"; +import { GameModes, getGameMode } from "#app/game-mode.js"; +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.js"; +import { EnemyCommandPhase } from "#app/phases/enemy-command-phase.js"; +import { DamagePhase } from "#app/phases/damage-phase.js"; + +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(15) + .battleType("single") + .startingLevel(100) + .enemyLevel(1000) + .disableTrainerWaves() + .moveset([Moves.KOWTOW_CLEAVE]) + .enemyMoveset(SPLASH_ONLY); + await game.classicMode.startBattle(); + game.scene.gameMode = getGameMode(GameModes.ENDLESS); + + // Transition from Wave 15 to Wave 16 in order to trigger biome switch + game.move.select(Moves.KOWTOW_CLEAVE); + await game.phaseInterceptor.runFrom(EnemyCommandPhase).to(DamagePhase); + await game.doKillOpponents(); + await game.toNextWave(); + + 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(game, [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); +}); diff --git a/src/test/utils/gameManager.ts b/src/test/utils/gameManager.ts index 60d07065090..afb9347124d 100644 --- a/src/test/utils/gameManager.ts +++ b/src/test/utils/gameManager.ts @@ -44,6 +44,7 @@ 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"; /** * Class to manage the game state and transitions between phases. @@ -60,6 +61,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. @@ -81,6 +83,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); } /** diff --git a/src/test/utils/helpers/reloadHelper.ts b/src/test/utils/helpers/reloadHelper.ts new file mode 100644 index 00000000000..6800792837c --- /dev/null +++ b/src/test/utils/helpers/reloadHelper.ts @@ -0,0 +1,89 @@ +import { BattleType } from "#app/battle"; +import PokemonData from "#app/system/pokemon-data"; +import ArenaData from "#app/system/arena-data"; +import PersistentModifierData from "#app/system/modifier-data"; +import ChallengeData from "#app/system/challenge-data"; +import TrainerData from "#app/system/trainer-data"; +import { SessionSaveData } from "#app/system/game-data"; +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 { + /** + * Return a save data object to be used for reloading the current session. + * @returns A save data object for the current session. + */ + getSessionSaveData() : SessionSaveData { + // Copied from game-data.ts + const scene = this.game.scene; + const ret = { + seed: scene.seed, + playTime: scene.sessionPlayTime, + gameMode: scene.gameMode.modeId, + party: scene.getParty().map(p => new PokemonData(p)), + enemyParty: scene.getEnemyParty().map(p => new PokemonData(p)), + modifiers: scene.findModifiers(() => true).map(m => new PersistentModifierData(m, true)), + enemyModifiers: scene.findModifiers(() => true, false).map(m => new PersistentModifierData(m, false)), + arena: new ArenaData(scene.arena), + pokeballCounts: scene.pokeballCounts, + money: scene.money, + score: scene.score, + waveIndex: scene.currentBattle.waveIndex, + battleType: scene.currentBattle.battleType, + trainer: scene.currentBattle.battleType === BattleType.TRAINER ? new TrainerData(scene.currentBattle.trainer) : null, + gameVersion: scene.game.config.gameVersion, + timestamp: new Date().getTime(), + challenges: scene.gameMode.challenges.map(c => new ChallengeData(c)) + } as SessionSaveData; + return ret; + } + + /** + * 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 { + const scene = this.game.scene; + const sessionData = this.getSessionSaveData(); + 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]=================="); + } +} diff --git a/src/test/utils/phaseInterceptor.ts b/src/test/utils/phaseInterceptor.ts index de65405abff..1b368334132 100644 --- a/src/test/utils/phaseInterceptor.ts +++ b/src/test/utils/phaseInterceptor.ts @@ -16,6 +16,7 @@ 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.js"; import { NextEncounterPhase } from "#app/phases/next-encounter-phase"; import { PartyHealPhase } from "#app/phases/party-heal-phase"; import { PostSummonPhase } from "#app/phases/post-summon-phase"; @@ -62,6 +63,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], @@ -237,6 +239,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. */