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(); 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 { private startCurrentPhase(): void {
if (!this.currentPhase) { if (!this.currentPhase) {
return; return;

View File

@ -64,16 +64,13 @@ describe("Test misc", () => {
expect(data).toBeDefined(); expect(data).toBeDefined();
}); });
it("testing wait phase queue", async () => { it("testing waitUntil", async () => {
const fakeScene = { let a = 1;
phaseQueue: [1, 2, 3], // Initially not empty
};
setTimeout(() => { setTimeout(() => {
fakeScene.phaseQueue = []; a = 0;
}, 500); }, 500);
const spy = vi.fn(); const spy = vi.fn();
await waitUntil(() => fakeScene.phaseQueue.length === 0).then(result => { await waitUntil(() => a === 0).then(() => {
expect(result).toBe(true);
spy(); // Call the spy function spy(); // Call the spy function
}); });
expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalled();

View File

@ -54,7 +54,7 @@ import TextInterceptor from "#test/testUtils/TextInterceptor";
import { AES, enc } from "crypto-js"; import { AES, enc } from "crypto-js";
import fs from "node:fs"; import fs from "node:fs";
import { expect, vi } from "vitest"; 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. * Class to manage the game state and transitions between phases.
@ -405,7 +405,15 @@ export default class GameManager {
* @param phaseTarget - The target phase. * @param phaseTarget - The target phase.
* @returns Whether the current phase matches 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; const targetName = typeof phaseTarget === "string" ? phaseTarget : phaseTarget.name;
return this.scene.phaseManager.getCurrentPhase()?.constructor.name === targetName; return this.scene.phaseManager.getCurrentPhase()?.constructor.name === targetName;
} }

View File

@ -87,14 +87,20 @@ function getTestRunStarters(seed: string, species?: SpeciesId[]): Starter[] {
return starters; 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 => { return new Promise(resolve => {
const interval = setInterval(() => { const interval = setInterval(() => {
if (truth()) { if (truth()) {
clearInterval(interval); 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 UI from "#app/ui/ui";
import type { PhaseString } from "#app/@types/phase-types"; import type { PhaseString } from "#app/@types/phase-types";
import { vi } from "vitest"; import { vi } from "vitest";
import { format } from "util";
interface PromptHandler { interface PromptHandler {
phaseTarget?: PhaseString; phaseTarget: PhaseString;
mode?: UiMode; mode: UiMode;
callback?: () => void; callback: () => void;
expireFn?: () => boolean; 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}. * The PhaseInterceptor is a wrapper around the `BattleScene`'s {@linkcode PhaseManager}.
* It allows tests to exert finer control over the phase system, providing logging, * It allows tests to exert finer control over the phase system, providing logging,
@ -31,9 +22,6 @@ export default class PhaseInterceptor {
private scene: BattleScene; private scene: BattleScene;
/** A log of phases having been executed. */ /** A log of phases having been executed. */
public log: PhaseString[] = []; public log: PhaseString[] = [];
private promptInterval: NodeJS.Timeout;
private intervalRun: NodeJS.Timeout;
// TODO: Move prompts to a separate class
private prompts: PromptHandler[] = []; private prompts: PromptHandler[] = [];
/** /**
@ -43,7 +31,6 @@ export default class PhaseInterceptor {
constructor(scene: BattleScene) { constructor(scene: BattleScene) {
this.scene = scene; this.scene = scene;
this.initMocks(); this.initMocks();
this.startPromptHandler();
} }
/** /**
@ -63,39 +50,10 @@ export default class PhaseInterceptor {
); );
// Mock the private startCurrentPhase method to do nothing to let us // Mock the private startCurrentPhase method to do nothing to let us
// handle starting phases manually in the `run` method. // start them manually ourselves.
vi.fn(this.scene.phaseManager["startCurrentPhase"]).mockImplementation(() => { this.scene.phaseManager["startCurrentPhase"] satisfies () => void; // typecheck in case function is renamed/removed
console.log("Did nothing!!!!"); // @ts-expect-error - startCurrentPhase is private
}); vi.spyOn(this.scene.phaseManager, "startCurrentPhase").mockImplementation(() => {});
}
/**
* 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();
}
}
});
} }
/** /**
@ -115,82 +73,110 @@ export default class PhaseInterceptor {
* @returns A promise that resolves when the transition is complete. * @returns A promise that resolves when the transition is complete.
*/ */
public async to(targetPhase: PhaseString, runTarget = true): Promise<void> { public async to(targetPhase: PhaseString, runTarget = true): Promise<void> {
this.intervalRun = setInterval(async () => { let currentPhase = this.scene.phaseManager.getCurrentPhase();
const currentPhase = this.scene.phaseManager.getCurrentPhase(); while (!currentPhase?.is(targetPhase)) {
currentPhase = this.scene.phaseManager.getCurrentPhase();
if (!currentPhase) { if (!currentPhase) {
console.log("No phases left to run; resolving."); console.log("Reached end of phases without hitting target; resolving.");
return; return;
} }
if (!currentPhase.is(targetPhase)) { // Current phase is different; run and wait for it to finish
// 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;
}
await this.run(currentPhase); 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; return;
}); }
console.log(`PhaseInterceptor.to: Running target ${targetPhase}`);
await this.run(currentPhase);
} }
/** /**
* Method to run a phase with an optional skip function. * Wrapper method to run a phase and start the next phase.
* @param phaseTarget - The {@linkcode Phase} to run. * @param currentPhase - The {@linkcode Phase} to run.
* @returns A Promise that resolves when the phase is run. * @returns A Promise that resolves when the phase is run.
*/ */
private async run(phaseTarget: Phase): Promise<void> { private async run(currentPhase: 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}`,
);
}
try { try {
this.logPhase(currentPhase.phaseName); this.logPhase(currentPhase.phaseName);
currentPhase.start(); currentPhase.start();
} catch (error: unknown) { } catch (error: unknown) {
throw error instanceof Error throw error instanceof Error
? 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() { shiftPhase() {
console.log(`Skipping current phase ${this.scene.phaseManager.getCurrentPhase()?.phaseName}`);
return this.scene.phaseManager.shiftPhase(); 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 originalSetMode - The original setMode method from the UI.
* @param mode - The {@linkcode UiMode} to set. * @param mode - The {@linkcode UiMode} to set.
* @param args - Additional arguments to pass to the original method. * @param args - Additional arguments to pass to the original method.
*/ */
private async setMode(originalSetMode: typeof UI.prototype.setMode, mode: UiMode, ...args: unknown[]): Promise<void> { private async setMode(
const currentPhase = this.scene.phaseManager.getCurrentPhase(); originalSetMode: typeof UI.prototype.setMode,
mode: UiMode,
...args: unknown[]
): ReturnType<typeof UI.prototype.setMode> {
console.log("setMode", `${UiMode[mode]} (=${mode})`, args); console.log("setMode", `${UiMode[mode]} (=${mode})`, args);
const ret = originalSetMode.apply(this.scene.ui, [mode, ...args]); const ret = originalSetMode.apply(this.scene.ui, [mode, ...args]);
if (currentPhase && endBySetMode.has(currentPhase.phaseName)) { this.doPromptCheck(mode);
await this.run(currentPhase);
}
return ret; return ret;
} }
/** /**
* Method to add an action to the next prompt. * Method to start the prompt handler.
* @param phaseTarget - The target phase for the prompt. */
* @param mode - The mode of the UI. 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 callback - The callback function to execute.
* @param expireFn - The function to determine if the prompt has expired. * @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( addToNextPrompt(
phaseTarget: PhaseString, phaseTarget: PhaseString,
@ -212,7 +198,7 @@ export default class PhaseInterceptor {
* Restores the original state of phases and clears intervals. * Restores the original state of phases and clears intervals.
*/ */
restoreOg() { restoreOg() {
clearInterval(this.promptInterval); // clearInterval(this.promptInterval);
clearInterval(this.intervalRun); // clearInterval(this.intervalRun);
} }
} }