Made interceptor use a while loop instead of timeouts

This commit is contained in:
Bertie690 2025-06-19 13:14:41 -04:00
parent 105f39088e
commit 1bcad94568
5 changed files with 108 additions and 104 deletions

View File

@ -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;

View File

@ -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();

View File

@ -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;
}

View File

@ -87,14 +87,20 @@ function getTestRunStarters(seed: string, species?: SpeciesId[]): Starter[] {
return starters;
}
export function waitUntil(truth): Promise<unknown> {
/**
* 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<void> {
return new Promise(resolve => {
const interval = setInterval(() => {
if (truth()) {
clearInterval(interval);
resolve(true);
resolve();
}
}, 1000);
}, timeout);
});
}

View File

@ -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<PhaseString> = 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<void> {
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<void> {
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<void> {
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<void> {
const currentPhase = this.scene.phaseManager.getCurrentPhase();
private async setMode(
originalSetMode: typeof UI.prototype.setMode,
mode: UiMode,
...args: unknown[]
): ReturnType<typeof UI.prototype.setMode> {
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);
}
}