From 1bcad94568b2b1f5ccb293069df00e48d6d9ce03 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Thu, 19 Jun 2025 13:14:41 -0400 Subject: [PATCH] Made interceptor use a while loop instead of timeouts --- src/phase-manager.ts | 7 ++ test/misc.test.ts | 11 +- test/testUtils/gameManager.ts | 12 +- test/testUtils/gameManagerUtils.ts | 12 +- test/testUtils/phaseInterceptor.ts | 170 +++++++++++++---------------- 5 files changed, 108 insertions(+), 104 deletions(-) diff --git a/src/phase-manager.ts b/src/phase-manager.ts index c59bd42eea6..e6e0b247405 100644 --- a/src/phase-manager.ts +++ b/src/phase-manager.ts @@ -391,6 +391,13 @@ export class PhaseManager { 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) { return; diff --git a/test/misc.test.ts b/test/misc.test.ts index 12ed165d9d9..05aa7a3d070 100644 --- a/test/misc.test.ts +++ b/test/misc.test.ts @@ -64,16 +64,13 @@ describe("Test misc", () => { expect(data).toBeDefined(); }); - it("testing wait phase queue", async () => { - const fakeScene = { - phaseQueue: [1, 2, 3], // Initially not empty - }; + it("testing waitUntil", async () => { + let a = 1; setTimeout(() => { - fakeScene.phaseQueue = []; + a = 0; }, 500); const spy = vi.fn(); - await waitUntil(() => fakeScene.phaseQueue.length === 0).then(result => { - expect(result).toBe(true); + await waitUntil(() => a === 0).then(() => { spy(); // Call the spy function }); expect(spy).toHaveBeenCalled(); diff --git a/test/testUtils/gameManager.ts b/test/testUtils/gameManager.ts index 73d06219902..d308668e13b 100644 --- a/test/testUtils/gameManager.ts +++ b/test/testUtils/gameManager.ts @@ -54,7 +54,7 @@ import TextInterceptor from "#test/testUtils/TextInterceptor"; import { AES, enc } from "crypto-js"; import fs from "node:fs"; import { expect, vi } from "vitest"; -import type { PhaseString } from "#app/@types/phase-types"; +import type { PhaseClass, PhaseString } from "#app/@types/phase-types"; /** * Class to manage the game state and transitions between phases. @@ -405,7 +405,15 @@ export default class GameManager { * @param phaseTarget - The target phase. * @returns Whether the current phase matches the target phase */ - isCurrentPhase(phaseTarget) { + isCurrentPhase(phaseTarget: PhaseString | PhaseClass); + /** + * 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 PhaseString + */ + isCurrentPhase(phaseTarget: PhaseClass); + isCurrentPhase(phaseTarget: PhaseString | PhaseClass) { const targetName = typeof phaseTarget === "string" ? phaseTarget : phaseTarget.name; return this.scene.phaseManager.getCurrentPhase()?.constructor.name === targetName; } diff --git a/test/testUtils/gameManagerUtils.ts b/test/testUtils/gameManagerUtils.ts index 57fd9b91d26..e652963e774 100644 --- a/test/testUtils/gameManagerUtils.ts +++ b/test/testUtils/gameManagerUtils.ts @@ -87,14 +87,20 @@ function getTestRunStarters(seed: string, species?: SpeciesId[]): Starter[] { return starters; } -export function waitUntil(truth): Promise { +/** + * Wait until a given function returns a truthy value. + * @param truth - A function to check against, called once per `timeout` interval. + * @param timeout - The time in milliseconds to wait before giving up; default `1000`. + * @returns A Promise that resolve once `truth` returns a truthy value. + */ +export function waitUntil(truth: () => boolean, timeout = 1000): Promise { return new Promise(resolve => { const interval = setInterval(() => { if (truth()) { clearInterval(interval); - resolve(true); + resolve(); } - }, 1000); + }, timeout); }); } diff --git a/test/testUtils/phaseInterceptor.ts b/test/testUtils/phaseInterceptor.ts index 6602dc969a6..c196a74451a 100644 --- a/test/testUtils/phaseInterceptor.ts +++ b/test/testUtils/phaseInterceptor.ts @@ -4,25 +4,16 @@ import { UiMode } from "#enums/ui-mode"; import UI from "#app/ui/ui"; import type { PhaseString } from "#app/@types/phase-types"; import { vi } from "vitest"; +import { format } from "util"; interface PromptHandler { - phaseTarget?: PhaseString; - mode?: UiMode; - callback?: () => void; + phaseTarget: PhaseString; + mode: UiMode; + callback: () => void; expireFn?: () => boolean; - awaitingActionInput?: boolean; + awaitingActionInput: boolean; } -/** Array of phases which end via player input. */ -const endBySetMode: Set = new Set([ - "TitlePhase", - "SelectGenderPhase", - "CommandPhase", - "SelectModifierPhase", - "MysteryEncounterPhase", - "PostMysteryEncounterPhase", -]); - /** * The PhaseInterceptor is a wrapper around the `BattleScene`'s {@linkcode PhaseManager}. * It allows tests to exert finer control over the phase system, providing logging, @@ -31,9 +22,6 @@ export default class PhaseInterceptor { private scene: BattleScene; /** A log of phases having been executed. */ public log: PhaseString[] = []; - private promptInterval: NodeJS.Timeout; - private intervalRun: NodeJS.Timeout; - // TODO: Move prompts to a separate class private prompts: PromptHandler[] = []; /** @@ -43,7 +31,6 @@ export default class PhaseInterceptor { constructor(scene: BattleScene) { this.scene = scene; this.initMocks(); - this.startPromptHandler(); } /** @@ -63,39 +50,10 @@ export default class PhaseInterceptor { ); // Mock the private startCurrentPhase method to do nothing to let us - // handle starting phases manually in the `run` method. - vi.fn(this.scene.phaseManager["startCurrentPhase"]).mockImplementation(() => { - console.log("Did nothing!!!!"); - }); - } - - /** - * Method to start the prompt handler. - */ - private startPromptHandler() { - this.promptInterval = setInterval(() => { - const actionForNextPrompt = this.prompts[0] as PromptHandler | undefined; - if (!actionForNextPrompt) { - return; - } - const expireFn = actionForNextPrompt.expireFn?.(); - const currentMode = this.scene.ui.getMode(); - const currentPhase = this.scene.phaseManager.getCurrentPhase()?.phaseName; - const currentHandler = this.scene.ui.getHandler(); - if (expireFn) { - this.prompts.shift(); - } else if ( - currentMode === actionForNextPrompt.mode && - currentPhase === actionForNextPrompt.phaseTarget && - currentHandler.active && - (!actionForNextPrompt.awaitingActionInput || currentHandler["awaitingActionInput"]) - ) { - const prompt = this.prompts.shift(); - if (prompt?.callback) { - prompt.callback(); - } - } - }); + // start them manually ourselves. + this.scene.phaseManager["startCurrentPhase"] satisfies () => void; // typecheck in case function is renamed/removed + // @ts-expect-error - startCurrentPhase is private + vi.spyOn(this.scene.phaseManager, "startCurrentPhase").mockImplementation(() => {}); } /** @@ -115,82 +73,110 @@ export default class PhaseInterceptor { * @returns A promise that resolves when the transition is complete. */ public async to(targetPhase: PhaseString, runTarget = true): Promise { - this.intervalRun = setInterval(async () => { - const currentPhase = this.scene.phaseManager.getCurrentPhase(); + let currentPhase = this.scene.phaseManager.getCurrentPhase(); + while (!currentPhase?.is(targetPhase)) { + currentPhase = this.scene.phaseManager.getCurrentPhase(); if (!currentPhase) { - console.log("No phases left to run; resolving."); + console.log("Reached end of phases without hitting target; resolving."); return; } - if (!currentPhase.is(targetPhase)) { - // Current phase is different; run and wait for it to finish - await this.run(currentPhase); - return; - } - - // target phase reached; run as applicable and resolve - clearInterval(this.intervalRun); - if (!runTarget) { - console.log(`PhaseInterceptor.to: Stopping on run of ${targetPhase}`); - this.scene.phaseManager.unshiftPhase(currentPhase); - return; - } + // Current phase is different; run and wait for it to finish await this.run(currentPhase); + } + + // We hit the target; run as applicable and wrap up. + if (!runTarget) { + console.log(`PhaseInterceptor.to: Stopping on run of ${targetPhase}`); return; - }); + } + + console.log(`PhaseInterceptor.to: Running target ${targetPhase}`); + await this.run(currentPhase); } /** - * Method to run a phase with an optional skip function. - * @param phaseTarget - The {@linkcode Phase} to run. + * Wrapper method to run a phase and start the next phase. + * @param currentPhase - The {@linkcode Phase} to run. * @returns A Promise that resolves when the phase is run. */ - private async run(phaseTarget: Phase): Promise { - const currentPhase = this.scene.phaseManager.getCurrentPhase(); - if (!currentPhase?.is(phaseTarget.phaseName)) { - throw new Error( - `Wrong phase passed to PhaseInterceptor.run;\nthis is ${currentPhase?.phaseName} and not ${phaseTarget.phaseName}`, - ); - } - + private async run(currentPhase: Phase): Promise { try { this.logPhase(currentPhase.phaseName); currentPhase.start(); } catch (error: unknown) { throw error instanceof Error ? error - : new Error(`Unknown error ${error} occurred while running phase ${currentPhase?.phaseName}!`); + : new Error( + `Unknown error occurred while running phase ${currentPhase.phaseName}!\nError: ${format("%O", error)}`, + ); } } - /** Alias for {@linkcode PhaseManager.shiftPhase()} */ + /** Alias for {@linkcode PhaseManager.shiftPhase()}. */ shiftPhase() { + console.log(`Skipping current phase ${this.scene.phaseManager.getCurrentPhase()?.phaseName}`); return this.scene.phaseManager.shiftPhase(); } /** - * Method to set mode. + * Method to override UI mode setting with custom prompt support. * @param originalSetMode - The original setMode method from the UI. * @param mode - The {@linkcode UiMode} to set. * @param args - Additional arguments to pass to the original method. */ - private async setMode(originalSetMode: typeof UI.prototype.setMode, mode: UiMode, ...args: unknown[]): Promise { - const currentPhase = this.scene.phaseManager.getCurrentPhase(); + private async setMode( + originalSetMode: typeof UI.prototype.setMode, + mode: UiMode, + ...args: unknown[] + ): ReturnType { console.log("setMode", `${UiMode[mode]} (=${mode})`, args); const ret = originalSetMode.apply(this.scene.ui, [mode, ...args]); - if (currentPhase && endBySetMode.has(currentPhase.phaseName)) { - await this.run(currentPhase); - } + this.doPromptCheck(mode); return ret; } /** - * Method to add an action to the next prompt. - * @param phaseTarget - The target phase for the prompt. - * @param mode - The mode of the UI. + * Method to start the prompt handler. + */ + private doPromptCheck(uiMode: UiMode) { + const actionForNextPrompt = this.prompts[0] as PromptHandler | undefined; + if (!actionForNextPrompt) { + return; + } + + // Check for prompt expiry, removing prompt if applicable. + if (actionForNextPrompt.expireFn?.()) { + this.prompts.shift(); + return; + } + + // Check if the current mode, phase, and handler match the expected values. + // If not, we just skip and wait. + // TODO: Should this check all prompts or only the first one? + const currentPhase = this.scene.phaseManager.getCurrentPhase()?.phaseName; + const currentHandler = this.scene.ui.getHandler(); + if ( + uiMode === actionForNextPrompt.mode || + currentPhase !== actionForNextPrompt.phaseTarget || + !currentHandler.active || + (actionForNextPrompt.awaitingActionInput && !currentHandler["awaitingActionInput"]) + ) { + return; + } + + // Prompt matches; perform callback as applicable and return + this.prompts.shift(); + actionForNextPrompt.callback(); + } + + /** + * Method to add a callback to the next prompt. + * @param phaseTarget - The {@linkcode PhaseString | name} of the Phase to execute the callback during. + * @param mode - The {@linkcode UIMode} to wait for. * @param callback - The callback function to execute. * @param expireFn - The function to determine if the prompt has expired. - * @param awaitingActionInput + * @param awaitingActionInput - If `true`, will only activate when the current UI handler is waiting for input; default `false` */ addToNextPrompt( phaseTarget: PhaseString, @@ -212,7 +198,7 @@ export default class PhaseInterceptor { * Restores the original state of phases and clears intervals. */ restoreOg() { - clearInterval(this.promptInterval); - clearInterval(this.intervalRun); + // clearInterval(this.promptInterval); + // clearInterval(this.intervalRun); } }