Squashed changes... again

This commit is contained in:
Bertie690 2025-08-20 23:35:45 -04:00
parent 6311ba1f51
commit b2d796eca3
17 changed files with 831 additions and 541 deletions

View File

@ -3,7 +3,7 @@ import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("{{description}}", () => {
let phaserGame: Phaser.Game;
@ -15,10 +15,6 @@ describe("{{description}}", () => {
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override

View File

@ -392,7 +392,11 @@ export class BattleScene extends SceneBase {
});
}
create() {
/**
* Create game objects with loaded assets.
* Called by Phaser on new game start.
*/
create(): void {
this.scene.remove(LoadingScene.KEY);
initGameSpeed.apply(this);
this.inputController = new InputsController();
@ -417,6 +421,7 @@ export class BattleScene extends SceneBase {
this.ui?.update();
}
// TODO: Split this up into multiple sub-methods
launchBattle() {
this.arenaBg = this.add.sprite(0, 0, "plains_bg");
this.arenaBg.setName("sprite-arena-bg");
@ -597,6 +602,8 @@ export class BattleScene extends SceneBase {
this.arenaNextEnemy.setVisible(false);
for (const a of [this.arenaPlayer, this.arenaPlayerTransition, this.arenaEnemy, this.arenaNextEnemy]) {
// TODO: This seems questionable - we just initialized the arena sprites and then have to manually check if they're a sprite?
// This is likely the result of either extreme laziness or confusion
if (a instanceof Phaser.GameObjects.Sprite) {
a.setOrigin(0, 0);
}
@ -1138,6 +1145,7 @@ export class BattleScene extends SceneBase {
return this.currentBattle?.randSeedInt(range, min);
}
// TODO: Break up function - this does far too much in 1 sitting
reset(clearScene = false, clearData = false, reloadI18n = false): void {
if (clearData) {
this.gameData = new GameData();

View File

@ -389,12 +389,26 @@ export class PhaseManager {
}
this.conditionalQueue.push(...unactivatedConditionalPhases);
if (this.currentPhase) {
console.log(`%cStart Phase ${this.currentPhase.constructor.name}`, "color:green;");
this.currentPhase.start();
}
this.startCurrentPhase();
}
/**
* Helper method to start and log the current phase.
*
* @remarks
* This is disabled during tests by `phase-interceptor.ts` to allow for pausing execution at specific phases.
* As such, **do not remove or split this method** as it will break integration tests.
*/
private startCurrentPhase(): void {
if (!this.currentPhase) {
console.warn("Trying to start null phase!");
return;
}
console.log(`%cStart Phase ${this.currentPhase.phaseName}`, "color:green;");
this.currentPhase.start();
}
// TODO: Review if we can remove this
overridePhase(phase: Phase): boolean {
if (this.standbyPhase) {
return false;
@ -402,8 +416,7 @@ export class PhaseManager {
this.standbyPhase = this.currentPhase;
this.currentPhase = phase;
console.log(`%cStart Phase ${phase.constructor.name}`, "color:green;");
phase.start();
this.startCurrentPhase();
return true;
}

View File

@ -168,24 +168,22 @@ export class TitlePhase extends Phase {
globalScene.ui.setMode(UiMode.TITLE, config);
}
loadSaveSlot(slotId: number): void {
async loadSaveSlot(slotId: number): Promise<void> {
globalScene.sessionSlotId = slotId > -1 || !loggedInUser ? slotId : loggedInUser.lastSessionSlot;
globalScene.ui.setMode(UiMode.MESSAGE);
globalScene.ui.resetModeChain();
globalScene.gameData
.loadSession(slotId, slotId === -1 ? this.lastSessionData : undefined)
.then((success: boolean) => {
if (success) {
this.loaded = true;
globalScene.ui.showText(i18next.t("menu:sessionSuccess"), null, () => this.end());
} else {
this.end();
}
})
.catch(err => {
console.error(err);
globalScene.ui.showText(i18next.t("menu:failedToLoadSession"), null);
});
try {
const success = await globalScene.gameData.loadSession(slotId, slotId === -1 ? this.lastSessionData : undefined);
if (success) {
this.loaded = true;
globalScene.ui.showText(i18next.t("menu:sessionSuccess"), null, () => this.end());
} else {
this.end();
}
} catch (err) {
console.error(err);
globalScene.ui.showText(i18next.t("menu:failedToLoadSession"), null);
}
}
initDailyRun(): void {

View File

@ -73,31 +73,9 @@ describe("Moves - Parting Shot", () => {
SpeciesId.ABRA,
]);
// use Memento 3 times to debuff enemy
game.move.select(MoveId.MEMENTO);
await game.phaseInterceptor.to("FaintPhase");
expect(game.field.getPlayerPokemon().isFainted()).toBe(true);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnInitPhase", false);
game.move.select(MoveId.MEMENTO);
await game.phaseInterceptor.to("FaintPhase");
expect(game.field.getPlayerPokemon().isFainted()).toBe(true);
game.doSelectPartyPokemon(2);
await game.phaseInterceptor.to("TurnInitPhase", false);
game.move.select(MoveId.MEMENTO);
await game.phaseInterceptor.to("FaintPhase");
expect(game.field.getPlayerPokemon().isFainted()).toBe(true);
game.doSelectPartyPokemon(3);
// set up done
await game.phaseInterceptor.to("TurnInitPhase", false);
const enemyPokemon = game.field.getEnemyPokemon();
expect(enemyPokemon).toBeDefined();
expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-6);
expect(enemyPokemon.getStatStage(Stat.SPATK)).toBe(-6);
enemyPokemon.setStatStage(Stat.ATK, -6);
enemyPokemon.setStatStage(Stat.SPATK, -6);
// now parting shot should fail
game.move.select(MoveId.PARTING_SHOT);
@ -136,7 +114,6 @@ describe("Moves - Parting Shot", () => {
await game.classicMode.startBattle([SpeciesId.SNORLAX, SpeciesId.MEOWTH]);
const enemyPokemon = game.field.getEnemyPokemon();
expect(enemyPokemon).toBeDefined();
game.move.select(MoveId.PARTING_SHOT);

View File

@ -366,7 +366,7 @@ describe("Bug-Type Superfan - Mystery Encounter", () => {
await skipBattleRunMysteryEncounterRewardsPhase(game, false);
expect(scene.phaseManager.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterRewardsPhase.name);
game.phaseInterceptor["prompts"] = []; // Clear out prompt handlers
game.promptHandler["prompts"] = []; // Clear out prompt handlers
game.onNextPrompt("MysteryEncounterRewardsPhase", UiMode.OPTION_SELECT, () => {
game.endPhase();
});

View File

@ -1,49 +0,0 @@
export class ErrorInterceptor {
private static instance: ErrorInterceptor;
public running;
constructor() {
this.running = [];
}
public static getInstance(): ErrorInterceptor {
if (!ErrorInterceptor.instance) {
ErrorInterceptor.instance = new ErrorInterceptor();
}
return ErrorInterceptor.instance;
}
clear() {
this.running = [];
}
add(obj) {
this.running.push(obj);
}
remove(obj) {
const index = this.running.indexOf(obj);
if (index !== -1) {
this.running.splice(index, 1);
}
}
}
process.on("uncaughtException", error => {
console.log(error);
const toStop = ErrorInterceptor.getInstance().running;
for (const elm of toStop) {
elm.rejectAll(error);
}
global.testFailed = true;
});
// Global error handler for unhandled promise rejections
process.on("unhandledRejection", (reason, _promise) => {
console.log(reason);
const toStop = ErrorInterceptor.getInstance().running;
for (const elm of toStop) {
elm.rejectAll(reason);
}
global.testFailed = true;
});

View File

@ -28,7 +28,6 @@ import type { SelectTargetPhase } from "#phases/select-target-phase";
import { TurnEndPhase } from "#phases/turn-end-phase";
import { TurnInitPhase } from "#phases/turn-init-phase";
import { TurnStartPhase } from "#phases/turn-start-phase";
import { ErrorInterceptor } from "#test/test-utils/error-interceptor";
import { generateStarter } from "#test/test-utils/game-manager-utils";
import { GameWrapper } from "#test/test-utils/game-wrapper";
import { ChallengeModeHelper } from "#test/test-utils/helpers/challenge-mode-helper";
@ -38,12 +37,14 @@ import { FieldHelper } from "#test/test-utils/helpers/field-helper";
import { ModifierHelper } from "#test/test-utils/helpers/modifiers-helper";
import { MoveHelper } from "#test/test-utils/helpers/move-helper";
import { OverridesHelper } from "#test/test-utils/helpers/overrides-helper";
import { PromptHandler } from "#test/test-utils/helpers/prompt-handler";
import { ReloadHelper } from "#test/test-utils/helpers/reload-helper";
import { SettingsHelper } from "#test/test-utils/helpers/settings-helper";
import type { InputsHandler } from "#test/test-utils/inputs-handler";
import { MockFetch } from "#test/test-utils/mocks/mock-fetch";
import { PhaseInterceptor } from "#test/test-utils/phase-interceptor";
import { TextInterceptor } from "#test/test-utils/text-interceptor";
import type { PhaseClass, PhaseString } from "#types/phase-types";
import type { BallUiHandler } from "#ui/ball-ui-handler";
import type { BattleMessageUiHandler } from "#ui/battle-message-ui-handler";
import type { CommandUiHandler } from "#ui/command-ui-handler";
@ -65,6 +66,7 @@ export class GameManager {
public phaseInterceptor: PhaseInterceptor;
public textInterceptor: TextInterceptor;
public inputsHandler: InputsHandler;
public readonly promptHandler: PromptHandler;
public readonly override: OverridesHelper;
public readonly move: MoveHelper;
public readonly classicMode: ClassicModeHelper;
@ -82,7 +84,6 @@ export class GameManager {
*/
constructor(phaserGame: Phaser.Game, bypassLogin = true) {
localStorage.clear();
ErrorInterceptor.getInstance().clear();
// Simulate max rolls on RNG functions
// TODO: Create helpers for disabling/enabling battle RNG
BattleScene.prototype.randBattleSeedInt = (range, min = 0) => min + range - 1;
@ -102,6 +103,7 @@ export class GameManager {
}
this.textInterceptor = new TextInterceptor(this.scene);
this.promptHandler = new PromptHandler(this);
this.override = new OverridesHelper(this);
this.move = new MoveHelper(this);
this.classicMode = new ClassicModeHelper(this);
@ -157,7 +159,8 @@ export class GameManager {
}
/**
* End the currently running phase immediately.
* End the current phase immediately.
* @see {@linkcode PhaseInterceptor.shiftPhase} Function to skip the next upcoming phase
*/
endPhase() {
this.scene.phaseManager.getCurrentPhase()?.end();
@ -170,15 +173,18 @@ export class GameManager {
* @param mode - The mode to wait for.
* @param callback - The callback function to execute on next prompt.
* @param expireFn - Optional function to determine if the prompt has expired.
* @param awaitingActionInput - If true, will prevent the prompt from activating until the current {@linkcode AwaitableUiHandler}
* is awaiting input; default `false`
* @todo Remove in favor of {@linkcode promptHandler.addToNextPrompt}
*/
onNextPrompt(
phaseTarget: string,
phaseTarget: PhaseString,
mode: UiMode,
callback: () => void,
expireFn?: () => void,
expireFn?: () => boolean,
awaitingActionInput = false,
) {
this.phaseInterceptor.addToNextPrompt(phaseTarget, mode, callback, expireFn, awaitingActionInput);
this.promptHandler.addToNextPrompt(phaseTarget, mode, callback, expireFn, awaitingActionInput);
}
/**
@ -188,7 +194,7 @@ export class GameManager {
async runToTitle(): Promise<void> {
// Go to login phase and skip past it
await this.phaseInterceptor.to("LoginPhase", false);
this.phaseInterceptor.shiftPhase(true);
this.phaseInterceptor.shiftPhase();
await this.phaseInterceptor.to("TitlePhase");
// TODO: This should be moved to a separate initialization method
@ -365,14 +371,14 @@ export class GameManager {
* Transition to the first {@linkcode CommandPhase} of the next turn.
* @returns A promise that resolves once the next {@linkcode CommandPhase} has been reached.
*/
async toNextTurn() {
async toNextTurn(): Promise<void> {
await this.phaseInterceptor.to("TurnInitPhase");
await this.phaseInterceptor.to("CommandPhase");
console.log("==================[New Turn]==================");
}
/** Transition to the {@linkcode TurnEndPhase | end of the current turn}. */
async toEndOfTurn() {
async toEndOfTurn(): Promise<void> {
await this.phaseInterceptor.to("TurnEndPhase");
console.log("==================[End of Turn]==================");
}
@ -381,7 +387,7 @@ export class GameManager {
* Queue up button presses to skip taking an item on the next {@linkcode SelectModifierPhase},
* and then transition to the next {@linkcode CommandPhase}.
*/
async toNextWave() {
async toNextWave(): Promise<void> {
this.doSelectModifier();
// forcibly end the message box for switching pokemon
@ -404,7 +410,7 @@ export class GameManager {
* Check if the player has won the battle.
* @returns whether the player has won the battle (all opposing Pokemon have been fainted)
*/
isVictory() {
isVictory(): boolean {
return this.scene.currentBattle.enemyParty.every(pokemon => pokemon.isFainted());
}
@ -413,9 +419,17 @@ export class GameManager {
* @param phaseTarget - The target phase.
* @returns Whether the current phase matches the target phase
*/
isCurrentPhase(phaseTarget) {
const targetName = typeof phaseTarget === "string" ? phaseTarget : phaseTarget.name;
return this.scene.phaseManager.getCurrentPhase()?.constructor.name === targetName;
isCurrentPhase(phaseTarget: PhaseString): boolean;
/**
* Checks if the current phase matches the target phase.
* @param phaseTarget - The target phase.
* @returns Whether the current phase matches the target phase
* @deprecated - Use phaseClass
*/
isCurrentPhase(phaseTarget: PhaseClass): boolean;
isCurrentPhase(phaseTarget: PhaseString | PhaseClass): boolean {
const targetName = typeof phaseTarget === "string" ? phaseTarget : (phaseTarget.name as PhaseString);
return this.scene.phaseManager.getCurrentPhase()?.is(targetName) ?? false;
}
/**
@ -503,7 +517,7 @@ export class GameManager {
* @param inPhase - Which phase to expect the selection to occur in. Defaults to `SwitchPhase`
* (which is where the majority of non-command switch operations occur).
*/
doSelectPartyPokemon(slot: number, inPhase = "SwitchPhase") {
doSelectPartyPokemon(slot: number, inPhase: PhaseString = "SwitchPhase") {
this.onNextPrompt(inPhase, UiMode.PARTY, () => {
const partyHandler = this.scene.ui.getHandler() as PartyUiHandler;

View File

@ -47,12 +47,14 @@ export class GameWrapper {
public scene: BattleScene;
constructor(phaserGame: Phaser.Game, bypassLogin: boolean) {
// TODO: Figure out how to actually set RNG states correctly
Phaser.Math.RND.sow(["test"]);
// vi.spyOn(Utils, "apiFetch", "get").mockReturnValue(fetch);
if (bypassLogin) {
vi.spyOn(bypassLoginModule, "bypassLogin", "get").mockReturnValue(true);
}
this.game = phaserGame;
// TODO: Move these mocks elsewhere
MoveAnim.prototype.getAnim = () => ({
frames: {},
});
@ -71,10 +73,16 @@ export class GameWrapper {
PokedexMonContainer.prototype.remove = MockContainer.prototype.remove;
}
setScene(scene: BattleScene) {
/**
* Initialize the given {@linkcode BattleScene} and override various properties to avoid crashes with headless games.
* @param scene - The {@linkcode BattleScene} to initialize
* @returns A Promise that resolves once the initialization process has completed.
*/
// TODO: is asset loading & method overriding actually needed for a headless renderer?
async setScene(scene: BattleScene): Promise<void> {
this.scene = scene;
this.injectMandatory();
this.scene.preload?.();
this.scene.preload();
this.scene.create();
}

View File

@ -0,0 +1,159 @@
import type { AwaitableUiHandler } from "#app/ui/awaitable-ui-handler";
import { UiMode } from "#enums/ui-mode";
import type { GameManager } from "#test/test-utils/game-manager";
import { GameManagerHelper } from "#test/test-utils/helpers/game-manager-helper";
import type { PhaseString } from "#types/phase-types";
import chalk from "chalk";
import { type MockInstance, vi } from "vitest";
interface UIPrompt {
/** The {@linkcode PhaseString | name} of the Phase during which to execute the callback. */
phaseTarget: PhaseString;
/** The {@linkcode UIMode} to wait for. */
mode: UiMode;
/** The callback function to execute. */
callback: () => void;
/**
* An optional callback function to determine if the prompt has expired and should be removed.
* Expired prompts are removed upon the next UI mode change without executing their callback.
*/
expireFn?: () => boolean;
/**
* If `true`, restricts the prompt to only activate when the current {@linkcode AwaitableUiHandler} is waiting for input.
* @defaultValue `false`
*/
awaitingActionInput: boolean;
}
/**
* Array of phases that hang whiile waiting for player input.
* Changing UI modes during these phases will halt the phase interceptor.
* @todo This is an extremely unintuitive solution that only works on a select few phases
* and does not account for UI handlers not accepting input
*/
const endBySetMode: ReadonlyArray<PhaseString> = [
"CommandPhase",
"TitlePhase",
"SelectGenderPhase",
"SelectStarterPhase",
"SelectModifierPhase",
"MysteryEncounterPhase",
"PostMysteryEncounterPhase",
];
/** Helper class to handle executing prompts upon UI mode changes. */
export class PromptHandler extends GameManagerHelper {
/** An array of {@linkcode UIPrompt | prompts} with associated callbacks. */
private prompts: UIPrompt[] = [];
/** The original `setModeInternal` function, stored for use in {@linkcode setMode}. */
private originalSetModeInternal: (typeof this.game.scene.ui)["setModeInternal"];
public static runInterval?: NodeJS.Timeout;
constructor(game: GameManager) {
super(game);
this.originalSetModeInternal = this.game.scene.ui["setModeInternal"];
// `any` assertion needed as we are mocking private property
(
vi.spyOn(this.game.scene.ui as any, "setModeInternal") as MockInstance<
(typeof this.game.scene.ui)["setModeInternal"]
>
).mockImplementation((...args) => this.setMode(args));
// Set an interval to repeatedly check the current prompt.
if (PromptHandler.runInterval) {
throw new Error("Prompt handler run interval was not properly cleared on test end!");
}
PromptHandler.runInterval = setInterval(() => this.doPromptCheck());
}
/**
* Helper method to wrap UI mode changing.
* @param args - Arguments being passed to the original method
* @returns The original return value.
*/
private setMode(args: Parameters<typeof this.originalSetModeInternal>) {
const mode = args[0];
this.doLog(`UI mode changed to ${UiMode[mode]} (=${mode})!`);
const ret = this.originalSetModeInternal.apply(this.game.scene.ui, args) as ReturnType<
typeof this.originalSetModeInternal
>;
const currentPhase = this.game.scene.phaseManager.getCurrentPhase()?.phaseName!;
if (endBySetMode.includes(currentPhase)) {
this.game.phaseInterceptor.checkMode();
}
return ret;
}
/**
* Method to perform prompt handling every so often.
* @param uiMode - The {@linkcode UiMode} being set
*/
private doPromptCheck(): void {
if (this.prompts.length === 0) {
return;
}
const prompt = this.prompts[0];
// remove expired prompts
if (prompt.expireFn?.()) {
this.prompts.shift();
return;
}
const currentPhase = this.game.scene.phaseManager.getCurrentPhase()?.phaseName;
const currentHandler = this.game.scene.ui.getHandler();
const mode = this.game.scene.ui.getMode();
// If the current mode, phase, and handler match the expected values, execute the callback and continue.
// If not, leave it there.
if (
mode === prompt.mode &&
currentPhase === prompt.phaseTarget &&
currentHandler.active &&
!(prompt.awaitingActionInput && !(currentHandler as AwaitableUiHandler)["awaitingActionInput"])
) {
prompt.callback();
this.prompts.shift();
}
}
/**
* Queue a callback to be executed on the next UI mode change.
* This can be used to (among other things) simulate inputs or run callbacks mid-phase.
* @param phaseTarget - The {@linkcode PhaseString | name} of the Phase during which the callback will be executed
* @param mode - The {@linkcode UiMode} to wait for
* @param callback - The callback function to execute
* @param expireFn - Optional function to determine if the prompt has expired
* @param awaitingActionInput - If `true`, restricts the prompt to only activate when the current {@linkcode AwaitableUiHandler} is waiting for input; default `false`
* @remarks
* If multiple prompts are queued up in succession, each will be checked in turn **until the first prompt that neither expires nor matches**.
* @todo Review all uses of this function to check if they can be made synchronous
*/
public addToNextPrompt(
phaseTarget: PhaseString,
mode: UiMode,
callback: () => void,
expireFn?: () => boolean,
awaitingActionInput = false,
) {
this.prompts.push({
phaseTarget,
mode,
callback,
expireFn,
awaitingActionInput,
});
}
/**
* Wrapper function to add green coloration to phase logs.
* @param args - Arguments to original logging function.
*/
private doLog(...args: unknown[]): void {
console.log(chalk.hex("#ffa500")(...args));
}
}

View File

@ -38,11 +38,7 @@ export class ReloadHelper extends GameManagerHelper {
scene.phaseManager.clearPhaseQueue();
// Set the last saved session to the desired session data
vi.spyOn(scene.gameData, "getSession").mockReturnValue(
new Promise((resolve, _reject) => {
resolve(this.sessionData);
}),
);
vi.spyOn(scene.gameData, "getSession").mockReturnValue(Promise.resolve(this.sessionData));
scene.phaseManager.unshiftPhase(titlePhase);
this.game.endPhase(); // End the currently ongoing battle
@ -56,8 +52,7 @@ export class ReloadHelper extends GameManagerHelper {
);
this.game.scene.modifiers = [];
}
titlePhase.loadSaveSlot(-1); // Load the desired session data
this.game.phaseInterceptor.shiftPhase(); // Loading the save slot also ended TitlePhase, clean it up
await titlePhase.loadSaveSlot(-1); // Load the desired session data
// Run through prompts for switching Pokemon, copied from classicModeHelper.ts
if (this.game.scene.battleStyle === BattleStyle.SWITCH) {

View File

@ -0,0 +1,11 @@
import { Phase } from "#app/phase";
/**
* A rudimentary mock of a phase.
* Ends upon starting by default.
*/
export abstract class mockPhase extends Phase {
public phaseName: any;
start() {
this.end();
}
}

View File

@ -1,445 +1,224 @@
import type { PhaseString } from "#app/@types/phase-types";
import type { BattleScene } from "#app/battle-scene";
import { Phase } from "#app/phase";
import type { Phase } from "#app/phase";
import type { Constructor } from "#app/utils/common";
import { UiMode } from "#enums/ui-mode";
import { AttemptRunPhase } from "#phases/attempt-run-phase";
import { BattleEndPhase } from "#phases/battle-end-phase";
import { BerryPhase } from "#phases/berry-phase";
import { CheckSwitchPhase } from "#phases/check-switch-phase";
import { CommandPhase } from "#phases/command-phase";
import { DamageAnimPhase } from "#phases/damage-anim-phase";
import { EggLapsePhase } from "#phases/egg-lapse-phase";
import { EncounterPhase } from "#phases/encounter-phase";
import { EndEvolutionPhase } from "#phases/end-evolution-phase";
import { EnemyCommandPhase } from "#phases/enemy-command-phase";
import { EvolutionPhase } from "#phases/evolution-phase";
import { ExpPhase } from "#phases/exp-phase";
import { FaintPhase } from "#phases/faint-phase";
import { FormChangePhase } from "#phases/form-change-phase";
import { GameOverModifierRewardPhase } from "#phases/game-over-modifier-reward-phase";
import { GameOverPhase } from "#phases/game-over-phase";
import { LearnMovePhase } from "#phases/learn-move-phase";
import { LevelCapPhase } from "#phases/level-cap-phase";
import { LoginPhase } from "#phases/login-phase";
import { MessagePhase } from "#phases/message-phase";
import { ModifierRewardPhase } from "#phases/modifier-reward-phase";
import { MoveEffectPhase } from "#phases/move-effect-phase";
import { MoveEndPhase } from "#phases/move-end-phase";
import { MovePhase } from "#phases/move-phase";
import {
MysteryEncounterBattlePhase,
MysteryEncounterOptionSelectedPhase,
MysteryEncounterPhase,
MysteryEncounterRewardsPhase,
PostMysteryEncounterPhase,
} from "#phases/mystery-encounter-phases";
import { NewBattlePhase } from "#phases/new-battle-phase";
import { NewBiomeEncounterPhase } from "#phases/new-biome-encounter-phase";
import { NextEncounterPhase } from "#phases/next-encounter-phase";
import { PartyExpPhase } from "#phases/party-exp-phase";
import { PartyHealPhase } from "#phases/party-heal-phase";
import { PokemonTransformPhase } from "#phases/pokemon-transform-phase";
import { PositionalTagPhase } from "#phases/positional-tag-phase";
import { PostGameOverPhase } from "#phases/post-game-over-phase";
import { PostSummonPhase } from "#phases/post-summon-phase";
import { QuietFormChangePhase } from "#phases/quiet-form-change-phase";
import { RevivalBlessingPhase } from "#phases/revival-blessing-phase";
import { RibbonModifierRewardPhase } from "#phases/ribbon-modifier-reward-phase";
import { SelectBiomePhase } from "#phases/select-biome-phase";
import { SelectGenderPhase } from "#phases/select-gender-phase";
import { SelectModifierPhase } from "#phases/select-modifier-phase";
import { SelectStarterPhase } from "#phases/select-starter-phase";
import { SelectTargetPhase } from "#phases/select-target-phase";
import { ShinySparklePhase } from "#phases/shiny-sparkle-phase";
import { ShowAbilityPhase } from "#phases/show-ability-phase";
import { StatStageChangePhase } from "#phases/stat-stage-change-phase";
import { SummonPhase } from "#phases/summon-phase";
import { SwitchPhase } from "#phases/switch-phase";
import { SwitchSummonPhase } from "#phases/switch-summon-phase";
import { TitlePhase } from "#phases/title-phase";
import { ToggleDoublePositionPhase } from "#phases/toggle-double-position-phase";
import { TurnEndPhase } from "#phases/turn-end-phase";
import { TurnInitPhase } from "#phases/turn-init-phase";
import { TurnStartPhase } from "#phases/turn-start-phase";
import { UnavailablePhase } from "#phases/unavailable-phase";
import { UnlockPhase } from "#phases/unlock-phase";
import { VictoryPhase } from "#phases/victory-phase";
import { ErrorInterceptor } from "#test/test-utils/error-interceptor";
import type { PhaseClass, PhaseString } from "#types/phase-types";
import type { AwaitableUiHandler } from "#ui/awaitable-ui-handler";
import { UI } from "#ui/ui";
// biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports
import type { GameManager } from "#test/test-utils/game-manager";
import type { PromptHandler } from "#test/test-utils/helpers/prompt-handler";
// biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports
import { format } from "util";
import chalk from "chalk";
import { vi } from "vitest";
export interface PromptHandler {
phaseTarget?: string;
mode?: UiMode;
callback?: () => void;
expireFn?: () => void;
awaitingActionInput?: boolean;
}
/**
* A Set containing phase names that will not be shown in the console when started.
*
* Used to reduce console noise from very repetitive phases.
*/
const blacklistedPhaseNames: ReadonlySet<PhaseString> = new Set(["ActivatePriorityQueuePhase"]);
type PhaseInterceptorPhase = PhaseClass | PhaseString;
interface PhaseStub {
start(): void;
endBySetMode: boolean;
}
interface InProgressStub {
name: string;
callback(): void;
onError(error: any): void;
}
interface onHoldStub {
name: string;
call(): void;
}
/**
* The interceptor's current state.
* Possible values are the following:
* - `running`: The interceptor is currently running a phase.
* - `interrupted`: The interceptor has been interrupted by a UI prompt or similar mechanism,
* and is currently waiting for the current phase to end.
* - `idling`: The interceptor is not currently running a phase and is ready to start a new one.
*/
type StateType = "running" | "interrupted" | "idling";
/**
* The PhaseInterceptor is a wrapper around the `BattleScene`'s {@linkcode PhaseManager}.
* It allows tests to exert finer control over the phase system, providing logging, manual advancing, and other helpful utilities.
*/
export class PhaseInterceptor {
public scene: BattleScene;
// @ts-expect-error: initialized in `initPhases`
public phases: Record<PhaseString, PhaseStub> = {};
public log: PhaseString[];
private scene: BattleScene;
/**
* TODO: This should not be an array;
* Our linear phase system means only 1 phase is ever started at once (if any)
* A log containing all phases having been executed in FIFO order. \
* Entries are appended each time {@linkcode run} is called, and can be cleared manually with {@linkcode clearLogs}.
*/
private onHold: onHoldStub[];
private interval: NodeJS.Timeout;
private promptInterval: NodeJS.Timeout;
private intervalRun: NodeJS.Timeout;
private prompts: PromptHandler[];
private inProgress?: InProgressStub;
private originalSetMode: UI["setMode"];
private originalSuperEnd: Phase["end"];
public log: PhaseString[] = [];
/**
* List of phases with their corresponding start methods.
*
* CAUTION: If a phase and its subclasses (if any) both appear in this list,
* make sure that this list contains said phase AFTER all of its subclasses.
* This way, the phase's `prototype.start` is properly preserved during
* `initPhases()` so that its subclasses can use `super.start()` properly.
* The interceptor's current state.
* Possible values are the following:
* - `running`: The interceptor is currently running a phase.
* - `interrupted`: The interceptor has been interrupted by a UI prompt and is waiting for the caller to end it.
* - `idling`: The interceptor is not currently running a phase and is ready to start a new one.
*/
private PHASES = [
LoginPhase,
TitlePhase,
SelectGenderPhase,
NewBiomeEncounterPhase,
SelectStarterPhase,
PostSummonPhase,
SummonPhase,
ToggleDoublePositionPhase,
CheckSwitchPhase,
ShowAbilityPhase,
MessagePhase,
TurnInitPhase,
CommandPhase,
EnemyCommandPhase,
TurnStartPhase,
MovePhase,
MoveEffectPhase,
DamageAnimPhase,
FaintPhase,
BerryPhase,
TurnEndPhase,
BattleEndPhase,
EggLapsePhase,
SelectModifierPhase,
NextEncounterPhase,
NewBattlePhase,
VictoryPhase,
LearnMovePhase,
MoveEndPhase,
StatStageChangePhase,
ShinySparklePhase,
SelectTargetPhase,
UnavailablePhase,
QuietFormChangePhase,
SwitchPhase,
SwitchSummonPhase,
PartyHealPhase,
FormChangePhase,
EvolutionPhase,
EndEvolutionPhase,
LevelCapPhase,
AttemptRunPhase,
SelectBiomePhase,
PositionalTagPhase,
PokemonTransformPhase,
MysteryEncounterPhase,
MysteryEncounterOptionSelectedPhase,
MysteryEncounterBattlePhase,
MysteryEncounterRewardsPhase,
PostMysteryEncounterPhase,
RibbonModifierRewardPhase,
GameOverModifierRewardPhase,
ModifierRewardPhase,
PartyExpPhase,
ExpPhase,
EncounterPhase,
GameOverPhase,
UnlockPhase,
PostGameOverPhase,
RevivalBlessingPhase,
];
private state: StateType = "idling";
private endBySetMode = [
TitlePhase,
SelectGenderPhase,
CommandPhase,
SelectStarterPhase,
SelectModifierPhase,
MysteryEncounterPhase,
PostMysteryEncounterPhase,
];
private target: PhaseString;
/**
* Constructor to initialize the scene and properties, and to start the phase handling.
* @param scene - The scene to be managed.
* @param scene - The scene to be managed
*/
constructor(scene: BattleScene) {
this.scene = scene;
this.onHold = [];
this.prompts = [];
this.clearLogs();
this.startPromptHandler();
this.initPhases();
}
/**
* Clears phase logs
*/
clearLogs() {
this.log = [];
}
rejectAll(error) {
if (this.inProgress) {
clearInterval(this.promptInterval);
clearInterval(this.interval);
clearInterval(this.intervalRun);
this.inProgress.onError(error);
}
// Mock the private startCurrentPhase method to toggle `isRunning` rather than actually starting anything
vi.spyOn(this.scene.phaseManager as any, "startCurrentPhase").mockImplementation(() => {
this.state = "idling";
});
}
/**
* Method to transition to a target phase.
* @param phaseTo - The phase to transition to.
* @param runTarget - Whether or not to run the target phase; default `true`.
* @returns A promise that resolves when the transition is complete.
* @param target - The name of the {@linkcode Phase} to transition to
* @param runTarget - Whether or not to run the target phase before resolving; default `true`
* @returns A Promise that resolves once {@linkcode target} has been reached.
* @todo remove `Constructor` from type signature in favor of phase strings
* @remarks
* This will not resolve for *any* reason until the target phase has been reached.
* @example
* await game.phaseInterceptor.to("MoveEffectPhase", false);
*/
async to(phaseTo: PhaseInterceptorPhase, runTarget = true): Promise<void> {
return new Promise(async (resolve, reject) => {
ErrorInterceptor.getInstance().add(this);
const targetName = typeof phaseTo === "string" ? phaseTo : phaseTo.name;
this.intervalRun = setInterval(async () => {
const currentPhase = this.onHold?.length && this.onHold[0];
if (!currentPhase) {
// No current phase means the manager either hasn't started yet
// or we were interrupted by prompt; wait for phase to finish
return;
}
public async to(target: PhaseString | Constructor<Phase>, runTarget = true): Promise<void> {
this.target = typeof target === "string" ? target : (target.name as PhaseString);
// If current phase is different, run it and wait for it to finish.
if (currentPhase.name !== targetName) {
await this.run().catch(e => {
clearInterval(this.intervalRun);
return reject(e);
});
return;
}
const pm = this.scene.phaseManager;
// Hit target phase; run it and resolve
clearInterval(this.intervalRun);
if (!runTarget) {
return resolve();
}
await this.run().catch(e => {
clearInterval(this.intervalRun);
return reject(e);
});
return resolve();
});
});
}
// TODO: remove bangs once signature is updated
let currentPhase: Phase = pm.getCurrentPhase()!;
/**
* Method to run the current phase with an optional skip function.
* @returns A promise that resolves when the phase is run.
*/
private run(): Promise<void> {
// @ts-expect-error: This is apparently mandatory to avoid a crash; review if this is needed
this.scene.moveAnimations = null;
return new Promise(async (resolve, reject) => {
ErrorInterceptor.getInstance().add(this);
const interval = setInterval(async () => {
const currentPhase = this.onHold.shift();
if (currentPhase) {
clearInterval(interval);
this.inProgress = {
name: currentPhase.name,
callback: () => {
ErrorInterceptor.getInstance().remove(this);
resolve();
},
onError: error => reject(error),
};
currentPhase.call();
}
});
});
}
let didLog = false;
/**
* 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.
*/
shiftPhase(shouldRun = false): void {
this.onHold.shift();
if (shouldRun) {
this.scene.phaseManager.shiftPhase();
}
}
/**
* Method to initialize phases and their corresponding methods.
*/
initPhases() {
this.originalSetMode = UI.prototype.setMode;
this.originalSuperEnd = Phase.prototype.end;
UI.prototype.setMode = (mode, ...args) => this.setMode.call(this, mode, ...args);
Phase.prototype.end = () => this.superEndPhase.call(this);
for (const phase of this.PHASES) {
const originalStart = phase.prototype.start;
this.phases[phase.name] = {
start: originalStart,
endBySetMode: this.endBySetMode.some(elm => elm.name === phase.name),
};
phase.prototype.start = () => this.startPhase.call(this, phase);
}
}
/**
* Method to start a phase and log it.
* @param phase - The phase to start.
*/
startPhase(phase: PhaseClass) {
this.log.push(phase.name as PhaseString);
const instance = this.scene.phaseManager.getCurrentPhase();
this.onHold.push({
name: phase.name,
call: () => {
this.phases[phase.name].start.apply(instance);
},
});
}
/**
* Method to end a phase and log it.
* @param phase - The phase to start.
*/
private superEndPhase() {
const instance = this.scene.phaseManager.getCurrentPhase();
this.originalSuperEnd.apply(instance);
this.inProgress?.callback();
this.inProgress = undefined;
}
/**
* m2m to set mode.
* @param mode - The {@linkcode UiMode} to set.
* @param args - Additional arguments to pass to the original method.
*/
setMode(mode: UiMode, ...args: unknown[]): Promise<void> {
// TODO: remove the `!` in PR 6243 / after PR 6243 is merged
const currentPhase = this.scene.phaseManager.getCurrentPhase()!;
const instance = this.scene.ui;
console.log("setMode", `${UiMode[mode]} (=${mode})`, args);
const ret = this.originalSetMode.apply(instance, [mode, ...args]);
if (!this.phases[currentPhase.constructor.name]) {
throw new Error(
`missing ${currentPhase.constructor.name} in phaseInterceptor PHASES list --- Add it to PHASES inside of /test/utils/phaseInterceptor.ts`,
);
}
if (this.phases[currentPhase.constructor.name].endBySetMode) {
this.inProgress?.callback();
this.inProgress = undefined;
}
return ret;
}
/**
* Method to start the prompt handler.
*/
startPromptHandler() {
this.promptInterval = setInterval(() => {
if (this.prompts.length) {
const actionForNextPrompt = this.prompts[0];
const expireFn = actionForNextPrompt.expireFn?.();
const currentMode = this.scene.ui.getMode();
const currentPhase = this.scene.phaseManager.getCurrentPhase()?.constructor.name;
const currentHandler = this.scene.ui.getHandler();
if (expireFn) {
this.prompts.shift();
} else if (
currentMode === actionForNextPrompt.mode &&
currentPhase === actionForNextPrompt.phaseTarget &&
currentHandler.active &&
(!actionForNextPrompt.awaitingActionInput ||
(actionForNextPrompt.awaitingActionInput && (currentHandler as AwaitableUiHandler)["awaitingActionInput"]))
) {
const prompt = this.prompts.shift();
if (prompt?.callback) {
prompt.callback();
// NB: This has to use an interval to wait for UI prompts to activate.
// TODO: Rework after UI rework
await vi.waitUntil(
async () => {
// If we were interrupted by a UI prompt, we assume that the calling code will queue inputs to
// end the current phase manually, so we just wait for the phase to end from the caller.
if (this.state === "interrupted") {
if (!didLog) {
this.doLog("PhaseInterceptor.to: Waiting for phase to end after being interrupted!");
didLog = true;
}
return false;
}
}
});
}
/**
* Method to add an action to the next prompt.
* @param phaseTarget - The target phase for the prompt.
* @param mode - The mode of the UI.
* @param callback - The callback function to execute.
* @param expireFn - The function to determine if the prompt has expired.
* @param awaitingActionInput - ???; default `false`
*/
addToNextPrompt(
phaseTarget: string,
mode: UiMode,
callback: () => void,
expireFn?: () => void,
awaitingActionInput = false,
) {
this.prompts.push({
phaseTarget,
mode,
callback,
expireFn,
awaitingActionInput,
});
}
currentPhase = pm.getCurrentPhase()!;
// TODO: Remove proof-of-concept error throw after signature update
if (!currentPhase) {
throw new Error("currentPhase is null after being started!");
}
/**
* Restores the original state of phases and clears intervals.
*
* This function iterates through all phases and resets their `start` method to the original
* function stored in `this.phases`. Additionally, it clears the `promptInterval` and `interval`.
*/
restoreOg() {
for (const phase of this.PHASES) {
phase.prototype.start = this.phases[phase.name].start;
if (currentPhase.is(this.target)) {
return true;
}
// Current phase is different; run and wait for it to finish.
await this.run(currentPhase);
return false;
},
{ interval: 0, timeout: 20_000 },
);
// We hit the target; run as applicable and wrap up.
if (!runTarget) {
this.doLog(`PhaseInterceptor.to: Stopping before running ${this.target}`);
return;
}
UI.prototype.setMode = this.originalSetMode;
Phase.prototype.end = this.originalSuperEnd;
clearInterval(this.promptInterval);
clearInterval(this.interval);
clearInterval(this.intervalRun);
await this.run(currentPhase);
this.doLog(
`PhaseInterceptor.to: Stopping ${this.state === "interrupted" ? `after reaching UiMode.${UiMode[this.scene.ui.getMode()]} during` : "on completion of"} ${this.target}`,
);
}
/**
* Internal wrapper method to start a phase and wait until it finishes.
* @param currentPhase - The {@linkcode Phase} to run
* @returns A Promise that resolves when the phase has completed running.
*/
private async run(currentPhase: Phase): Promise<void> {
try {
this.state = "running";
this.logPhase(currentPhase.phaseName);
currentPhase.start();
await vi.waitUntil(
() => this.state !== "running",
{ interval: 50, timeout: 20_000 }, // TODO: Figure out an appropriate timeout for individual phases
);
} catch (error) {
throw error instanceof Error
? error
: new Error(
`Unknown error occurred while running phase ${currentPhase.phaseName}!\nError: ${format("%O", error)}`,
);
}
}
/**
* If this is at the target phase, unlock the interceptor and
* return control back to the caller once the calling phase has finished.
* @remarks
* This should not be called by anything other than {@linkcode PromptHandler}.
*/
public checkMode(): void {
const currentPhase = this.scene.phaseManager.getCurrentPhase()!;
if (!currentPhase.is(this.target) || this.state === "interrupted") {
// Wrong phase / already interrupted = do nothing
return;
}
// Interrupt the phase and return control to the caller
this.state = "interrupted";
}
/**
* Skip the next upcoming phase.
* @throws Error if currently running a phase.
* @remarks
* This function should be used for skipping phases _not yet started_.
* To end ones already in the process of running, use {@linkcode GameManager.endPhase}.
* @example
* await game.phaseInterceptor.to("LoginPhase", false);
* game.phaseInterceptor.shiftPhase();
*/
public shiftPhase(): void {
const phaseName = this.scene.phaseManager.getCurrentPhase()!.phaseName;
if (this.state !== "idling") {
throw new Error(`shiftPhase attempted to skip phase ${phaseName} mid-execution!`);
}
this.doLog(`Skipping current phase ${phaseName}`);
this.scene.phaseManager.shiftPhase();
}
/**
* Deprecated no-op function.
*
* This was previously used to reset timers created using `setInterval` to wait for phase end
* and undo various method stubs upon a test ending. \
* However, since we now use {@linkcode vi.waitUntil} and {@linkcode vi.spyOn} to perform these tasks
* respectively, this function has become no longer needed.
* @deprecated This is no longer needed and will be removed in a future PR
*/
public restoreOg() {}
/**
* Method to log the start of a phase.
* @param phaseName - The name of the phase to log.
*/
private logPhase(phaseName: PhaseString) {
if (!blacklistedPhaseNames.has(phaseName)) {
console.log(`%cStart Phase: ${phaseName}`, "color:green");
}
this.log.push(phaseName);
}
/**
* Clear all prior phase logs.
*/
public clearLogs(): void {
this.log = [];
}
/**
* Wrapper function to add coral coloration to phase logs.
* @param args - Arguments to original logging function.
*/
private doLog(...args: unknown[]): void {
console.log(chalk.hex("#ff7f50")(...args));
}
}

View File

@ -0,0 +1,153 @@
import type { PhaseString } from "#app/@types/phase-types";
import type { Phase } from "#app/phase";
import type { AwaitableUiHandler } from "#app/ui/awaitable-ui-handler";
import { UiMode } from "#enums/ui-mode";
import type { GameManager } from "#test/test-utils/game-manager";
import { PromptHandler } from "#test/test-utils/helpers/prompt-handler";
import type { PhaseInterceptor } from "#test/test-utils/phase-interceptor";
import type { UI } from "#ui/ui";
import { beforeAll, beforeEach, describe, expect, it, type Mock, vi } from "vitest";
describe("Test Utils - PromptHandler", () => {
let promptHandler: PromptHandler;
let handler: AwaitableUiHandler;
let callback1: Mock;
let callback2: Mock;
let setModeCallback: Mock;
let checkModeCallback: Mock;
beforeAll(() => {
setModeCallback = vi.fn();
checkModeCallback = vi.fn();
callback1 = vi.fn(() => console.log("callback 1 called!")).mockName("callback 1");
callback2 = vi.fn(() => console.log("callback 2 called!")).mockName("callback 2");
});
beforeEach(() => {
handler = {
active: true,
show: () => {},
awaitingActionInput: true,
} as unknown as AwaitableUiHandler;
promptHandler = new PromptHandler({
scene: {
ui: {
getHandler: () => handler,
setModeInternal: () => {
setModeCallback();
return Promise.resolve();
},
getMode: () => UiMode.TEST_DIALOGUE,
} as unknown as UI,
phaseManager: {
getCurrentPhase: () =>
({
phaseName: "CommandPhase",
}) as unknown as Phase,
},
},
phaseInterceptor: {
checkMode: () => {
checkModeCallback();
},
} as PhaseInterceptor,
} as GameManager);
});
function onNextPrompt(
target: string,
mode: UiMode,
callback: () => void,
expireFn?: () => boolean,
awaitingActionInput = false,
) {
promptHandler.addToNextPrompt(target as unknown as PhaseString, mode, callback, expireFn, awaitingActionInput);
}
describe("setMode", () => {
it("should wrap and pass along original function arguments from setModeInternal", async () => {
const setModeSpy = vi.spyOn(promptHandler as any, "setMode");
promptHandler["game"].scene.ui["setModeInternal"](UiMode.PARTY, false, false, false, []);
expect(setModeSpy).toHaveBeenCalledExactlyOnceWith([UiMode.PARTY, false, false, false, []]);
expect(setModeCallback).toHaveBeenCalledAfter(setModeSpy);
});
it("should call PhaseInterceptor.checkMode if current phase in `endBySetMode`", async () => {
promptHandler["game"].scene.ui["setModeInternal"](UiMode.PARTY, false, false, false, []);
expect(checkModeCallback).toHaveBeenCalledOnce();
});
});
describe("doPromptCheck", () => {
it("should check and remove the first prompt", async () => {
onNextPrompt("testDialoguePhase", UiMode.TEST_DIALOGUE, () => callback1());
onNextPrompt("testDialoguePhase", UiMode.TEST_DIALOGUE, () => callback2());
promptHandler["doPromptCheck"]();
expect(callback1).toHaveBeenCalled();
expect(callback2).not.toHaveBeenCalled();
expect(promptHandler["prompts"]).toHaveLength(1);
});
it.each<{ reason: string; callback: () => void }>([
{
reason: "wrong UI mode",
callback: () => onNextPrompt("testDialoguePhase", UiMode.ACHIEVEMENTS, () => callback1()),
},
{
reason: "wrong phase",
callback: () => onNextPrompt("wrong phase", UiMode.TEST_DIALOGUE, () => callback1()),
},
{
reason: "UI handler is inactive",
callback: () => {
handler.active = false;
onNextPrompt("testDialoguePhase", UiMode.TEST_DIALOGUE, () => callback1());
},
},
{
reason: "UI handler is not awaiting input",
callback: () => {
handler["awaitingActionInput"] = false;
onNextPrompt("testDialoguePhase", UiMode.TEST_DIALOGUE, () => callback1(), undefined, true);
},
},
])("should skip callback and keep in queue if $reason", async ({ callback }) => {
callback();
onNextPrompt("testDialoguePhase", UiMode.TEST_DIALOGUE, () => callback2);
promptHandler["doPromptCheck"]();
expect(callback1).not.toHaveBeenCalled();
expect(callback2).not.toHaveBeenCalled();
expect(promptHandler["prompts"]).toHaveLength(2);
});
it("should remove expired prompts without blocking", async () => {
onNextPrompt(
"testDialoguePhase",
UiMode.TEST_DIALOGUE,
() => callback1(),
() => true,
);
onNextPrompt(
"testDialoguePhase",
UiMode.TEST_DIALOGUE,
() => callback2(),
() => false,
);
promptHandler["doPromptCheck"]();
expect(callback1).not.toHaveBeenCalled();
expect(callback2).not.toHaveBeenCalled();
expect(promptHandler["prompts"]).toHaveLength(1);
promptHandler["doPromptCheck"]();
expect(callback2).toHaveBeenCalledOnce();
expect(promptHandler["prompts"]).toHaveLength(0);
});
});
});

View File

@ -0,0 +1,62 @@
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { UiMode } from "#enums/ui-mode";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Utils - Phase Interceptor - Integration", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
beforeEach(() => {
game = new GameManager(phaserGame);
});
it("runToTitle", async () => {
await game.runToTitle();
expect(game.scene.ui.getMode()).toBe(UiMode.TITLE);
expect(game.scene.phaseManager.getCurrentPhase()?.phaseName).toBe("TitlePhase");
});
it("runToSummon", async () => {
await game.classicMode.runToSummon([SpeciesId.ABOMASNOW]);
expect(game.scene.phaseManager.getCurrentPhase()?.phaseName).toBe("SummonPhase");
});
it("startBattle", async () => {
await game.classicMode.startBattle([SpeciesId.RABOOT]);
expect(game.scene.ui.getMode()).toBe(UiMode.COMMAND);
expect(game.scene.phaseManager.getCurrentPhase()?.phaseName).toBe("CommandPhase");
});
it("1 Full Turn", async () => {
await game.classicMode.startBattle([SpeciesId.REGIELEKI]);
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.toNextTurn();
expect(game.scene.ui.getMode()).toBe(UiMode.COMMAND);
expect(game.scene.phaseManager.getCurrentPhase()?.phaseName).toBe("CommandPhase");
});
it("should not break when phase ended early via prompt", async () => {
await game.classicMode.startBattle([SpeciesId.REGIELEKI]);
game.onNextPrompt("CommandPhase", UiMode.COMMAND, () => {
game.endPhase();
});
game.move.use(MoveId.BOUNCE);
await game.phaseInterceptor.to("EnemyCommandPhase");
});
});

View File

@ -0,0 +1,150 @@
import type { PhaseString } from "#app/@types/phase-types";
import { globalScene } from "#app/global-scene";
import type { Phase } from "#app/phase";
import type { Constructor } from "#app/utils/common";
import { GameManager } from "#test/test-utils/game-manager";
import { mockPhase } from "#test/test-utils/mocks/mock-phase";
import Phaser from "phaser";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
// TODO: Move these to `mock-phase.ts` if/when unit tests for the phase manager are created
class applePhase extends mockPhase {
public readonly phaseName = "applePhase";
}
class bananaPhase extends mockPhase {
public readonly phaseName = "bananaPhase";
}
class coconutPhase extends mockPhase {
public readonly phaseName = "coconutPhase";
}
class oneSecTimerPhase extends mockPhase {
public readonly phaseName = "oneSecTimerPhase";
start() {
setTimeout(() => {
console.log("1 sec passed!");
this.end();
}, 1000);
}
}
class unshifterPhase extends mockPhase {
public readonly phaseName = "unshifterPhase";
start() {
globalScene.phaseManager.unshiftPhase(new applePhase() as unknown as Phase);
globalScene.phaseManager.unshiftPhase(new bananaPhase() as unknown as Phase);
globalScene.phaseManager.unshiftPhase(new coconutPhase() as unknown as Phase);
this.end();
}
}
describe("Utils - Phase Interceptor - Unit", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
beforeEach(() => {
game = new GameManager(phaserGame);
setPhases(applePhase, bananaPhase, coconutPhase, bananaPhase, coconutPhase);
});
/**
* Helper function to set the phase manager's phases to the specified values and start the first one.
* @param phases - An array of constructors to {@linkcode Phase}s to set.
* Constructors must have no arguments.
*/
function setPhases(phase: Constructor<mockPhase>, ...phases: Constructor<mockPhase>[]) {
game.scene.phaseManager.clearAllPhases();
game.scene.phaseManager.phaseQueue = [phase, ...phases].map(m => new m()) as Phase[];
game.scene.phaseManager.shiftPhase(); // start the thing going
}
function getQueuedPhases(): string[] {
return game.scene.phaseManager["phaseQueuePrepend"]
.concat(game.scene.phaseManager.phaseQueue)
.map(p => p.phaseName);
}
function getCurrentPhaseName(): string {
return game.scene.phaseManager.getCurrentPhase()?.phaseName ?? "no phase";
}
/** Wrapper function to make TS not complain about incompatible argument typing on `PhaseString`. */
function to(phaseName: string, runTarget = true) {
return game.phaseInterceptor.to(phaseName as unknown as PhaseString, runTarget);
}
describe("to", () => {
it("should start the specified phase and resolve after it ends", async () => {
await to("applePhase");
expect(getCurrentPhaseName()).toBe("bananaPhase");
expect(getQueuedPhases()).toEqual(["coconutPhase", "bananaPhase", "coconutPhase"]);
expect(game.phaseInterceptor.log).toEqual(["applePhase"]);
});
it("should run to the specified phase without starting/logging", async () => {
await to("applePhase", false);
expect(getCurrentPhaseName()).toBe("applePhase");
expect(getQueuedPhases()).toEqual(["bananaPhase", "coconutPhase", "bananaPhase", "coconutPhase"]);
expect(game.phaseInterceptor.log).toEqual([]);
await to("applePhase", false);
// should not do anything
expect(getCurrentPhaseName()).toBe("applePhase");
expect(getQueuedPhases()).toEqual(["bananaPhase", "coconutPhase", "bananaPhase", "coconutPhase"]);
expect(game.phaseInterceptor.log).toEqual([]);
});
it("should run all phases between start and the first instance of target", async () => {
await to("coconutPhase");
expect(getCurrentPhaseName()).toBe("bananaPhase");
expect(getQueuedPhases()).toEqual(["coconutPhase"]);
expect(game.phaseInterceptor.log).toEqual(["applePhase", "bananaPhase", "coconutPhase"]);
});
it("should work on newly unshifted phases", async () => {
setPhases(unshifterPhase, coconutPhase); // adds applePhase, bananaPhase and coconutPhase to queue
await to("bananaPhase");
expect(getCurrentPhaseName()).toBe("coconutPhase");
expect(getQueuedPhases()).toEqual(["coconutPhase"]);
expect(game.phaseInterceptor.log).toEqual(["unshifterPhase", "applePhase", "bananaPhase"]);
});
it("should wait for asynchronous phases to end", async () => {
setPhases(oneSecTimerPhase, coconutPhase);
const callback = vi.fn(() => console.log("fffffff"));
const spy = vi.spyOn(oneSecTimerPhase.prototype, "end");
setTimeout(() => {
callback();
}, 500);
await to("coconutPhase");
expect(callback).toHaveBeenCalled();
expect(spy).toHaveBeenCalled();
});
});
describe("shift", () => {
it("should skip the next phase in line without starting it", async () => {
const startSpy = vi.spyOn(applePhase.prototype, "start");
game.phaseInterceptor.shiftPhase();
expect(getCurrentPhaseName()).toBe("bananaPhase");
expect(getQueuedPhases()).toEqual(["coconutPhase", "bananaPhase", "coconutPhase"]);
expect(startSpy).not.toHaveBeenCalled();
expect(game.phaseInterceptor.log).toEqual([]);
});
});
});

View File

@ -1,6 +1,7 @@
import "vitest-canvas-mock";
import { PromptHandler } from "#test/test-utils/helpers/prompt-handler";
import { initTests } from "#test/test-utils/test-file-initialization";
import { afterAll, beforeAll, vi } from "vitest";
import { afterAll, afterEach, beforeAll, vi } from "vitest";
/** Set the timezone to UTC for tests. */
@ -48,13 +49,28 @@ vi.mock("i18next", async importOriginal => {
return await importOriginal();
});
global.testFailed = false;
beforeAll(() => {
initTests();
process
.on("uncaughtException", err => {
clearInterval(PromptHandler.runInterval);
PromptHandler.runInterval = undefined;
throw err;
})
.on("unhandledRejection", err => {
clearInterval(PromptHandler.runInterval);
PromptHandler.runInterval = undefined;
throw err;
});
});
afterAll(() => {
global.server.close();
console.log("Closing i18n MSW server!");
});
afterEach(() => {
clearInterval(PromptHandler.runInterval);
PromptHandler.runInterval = undefined;
});