From 9b59fcbb5f802cd4a73527f5d6456604a0f03ed6 Mon Sep 17 00:00:00 2001 From: catloversg <152669316+catloversg@users.noreply.github.com> Date: Sun, 6 Jul 2025 08:10:51 +0700 Subject: [PATCH] BUGFIX: Running scripts may be loaded before main UI (#1726) --- src/NetscriptWorker.ts | 68 ++++++++++++++--------- src/Script/ScriptHelpers.ts | 10 ++-- src/engine.tsx | 4 +- src/ui/GameRoot.tsx | 105 +++++++++++++++++++++++++++++++++--- src/ui/UIEventEmitter.ts | 7 +++ src/utils/EventEmitter.ts | 2 +- test/jest/Save.test.ts | 2 + 7 files changed, 159 insertions(+), 39 deletions(-) create mode 100644 src/ui/UIEventEmitter.ts diff --git a/src/NetscriptWorker.ts b/src/NetscriptWorker.ts index 67a4087ae..493f3a3c8 100644 --- a/src/NetscriptWorker.ts +++ b/src/NetscriptWorker.ts @@ -29,6 +29,8 @@ import { ScriptArg } from "@nsdefs"; import { CompleteRunOptions, getRunningScriptsByArgs } from "./Netscript/NetscriptHelpers"; import { handleUnknownError } from "./utils/ErrorHandler"; import { isLegacyScript, resolveScriptFilePath, ScriptFilePath } from "./Paths/ScriptFilePath"; +import { Player } from "@player"; +import { UIEventEmitter, UIEventType } from "./ui/UIEventEmitter"; import { getErrorMessageWithStackAndCause } from "./utils/ErrorHelper"; import { exceptionAlert } from "./utils/helpers/exceptionAlert"; import { Result } from "./types"; @@ -215,36 +217,50 @@ function createAutoexec(server: BaseServer): RunningScript | null { */ export function loadAllRunningScripts(): void { /** - * Accept all parameters containing "?noscript". The "standard" parameter is "?noScripts", but new players may not - * notice the "s" character at the end of "noScripts". + * While loading the save data, the game engine calls this function to load all running scripts. With each script, we + * calculate the offline data, so we need the current "lastUpdate" and "playtimeSinceLastAug" from the save data. + * After the main UI is loaded and the logic of this function starts executing, those info in the Player object might be + * overwritten, so we need to save them here and use them later in "scriptCalculateOfflineProduction". */ - const skipScriptLoad = window.location.href.toLowerCase().includes("?noscript"); - if (skipScriptLoad) { - Terminal.warn("Skipped loading player scripts during startup"); - console.info("Skipping the load of any scripts during startup"); - } - for (const server of GetAllServers()) { - // Reset each server's RAM usage to 0 - server.ramUsed = 0; - - const rsList = server.savedScripts; - server.savedScripts = undefined; - if (skipScriptLoad || !rsList) { - // Start game with no scripts - continue; + const playerLastUpdate = Player.lastUpdate; + const playerPlaytimeSinceLastAug = Player.playtimeSinceLastAug; + const unsubscribe = UIEventEmitter.subscribe((event) => { + if (event !== UIEventType.MainUILoaded) { + return; } - if (server.hostname === "home") { - // Push autoexec script onto the front of the list - const runningScript = createAutoexec(server); - if (runningScript) { - rsList.unshift(runningScript); + unsubscribe(); + /** + * Accept all parameters containing "?noscript". The "standard" parameter is "?noScripts", but new players may not + * notice the "s" character at the end of "noScripts". + */ + const skipScriptLoad = window.location.href.toLowerCase().includes("?noscript"); + if (skipScriptLoad) { + Terminal.warn("Skipped loading player scripts during startup"); + console.info("Skipping the load of any scripts during startup"); + } + for (const server of GetAllServers()) { + // Reset each server's RAM usage to 0 + server.ramUsed = 0; + + const rsList = server.savedScripts; + server.savedScripts = undefined; + if (skipScriptLoad || !rsList) { + // Start game with no scripts + continue; + } + if (server.hostname === "home") { + // Push autoexec script onto the front of the list + const runningScript = createAutoexec(server); + if (runningScript) { + rsList.unshift(runningScript); + } + } + for (const runningScript of rsList) { + startWorkerScript(runningScript, server); + scriptCalculateOfflineProduction(runningScript, playerLastUpdate, playerPlaytimeSinceLastAug); } } - for (const runningScript of rsList) { - startWorkerScript(runningScript, server); - scriptCalculateOfflineProduction(runningScript); - } - } + }); } export function createRunningScriptInstance( diff --git a/src/Script/ScriptHelpers.ts b/src/Script/ScriptHelpers.ts index 0adfeedd6..eddd9f38e 100644 --- a/src/Script/ScriptHelpers.ts +++ b/src/Script/ScriptHelpers.ts @@ -11,10 +11,14 @@ import { scriptKey } from "../utils/helpers/scriptKey"; import type { ScriptFilePath } from "../Paths/ScriptFilePath"; -export function scriptCalculateOfflineProduction(runningScript: RunningScript): void { +export function scriptCalculateOfflineProduction( + runningScript: RunningScript, + playerLastUpdate: number, + playerPlaytimeSinceLastAug: number, +): void { //The Player object stores the last update time from when we were online const thisUpdate = new Date().getTime(); - const lastUpdate = Player.lastUpdate; + const lastUpdate = playerLastUpdate; const timePassed = Math.max((thisUpdate - lastUpdate) / 1000, 0); //Seconds //Calculate the "confidence" rating of the script's true production. This is based @@ -59,7 +63,7 @@ export function scriptCalculateOfflineProduction(runningScript: RunningScript): Player.gainHackingExp(expGain); let moneyGain = - (runningScript.onlineMoneyMade / Player.playtimeSinceLastAug) * timePassed * CONSTANTS.OfflineHackingIncome; + (runningScript.onlineMoneyMade / playerPlaytimeSinceLastAug) * timePassed * CONSTANTS.OfflineHackingIncome; if (!Number.isFinite(moneyGain)) { moneyGain = 0; } diff --git a/src/engine.tsx b/src/engine.tsx index 35d453acc..68d659990 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -262,10 +262,10 @@ const Engine = { offlineHackingIncome = 0; } Player.gainMoney(offlineHackingIncome, "hacking"); - // Process offline progress loadAllRunningScripts(); // This also takes care of offline production for those scripts + // Process offline progress if (Player.currentWork !== null) { Player.focus = true; Player.processWork(numCyclesOffline); @@ -418,7 +418,7 @@ const Engine = { Engine._lastUpdate = _thisUpdate - offset; Player.lastUpdate = _thisUpdate - offset; Engine.updateGame(diff); - if (GameCycleEvents.hasSubscibers()) { + if (GameCycleEvents.hasSubscribers()) { ReactDOM.unstable_batchedUpdates(() => { GameCycleEvents.emit(); }); diff --git a/src/ui/GameRoot.tsx b/src/ui/GameRoot.tsx index e7b1672c4..fd99f2de8 100644 --- a/src/ui/GameRoot.tsx +++ b/src/ui/GameRoot.tsx @@ -19,7 +19,7 @@ import { StockMarket } from "../StockMarket/StockMarket"; import type { ComplexPage } from "./Enums"; import type { IRouter, PageContext, PageWithContext } from "./Router"; -import { Page } from "./Router"; +import { isSimplePage, Page } from "./Router"; import { Overview } from "./React/Overview"; import { SidebarRoot } from "../Sidebar/ui/SidebarRoot"; import { AugmentationsRoot } from "../Augmentation/ui/AugmentationsRoot"; @@ -76,6 +76,7 @@ import { HistoryProvider } from "./React/Documentation"; import { GoRoot } from "../Go/ui/GoRoot"; import { Settings } from "../Settings/Settings"; import { isBitNodeFinished } from "../BitNode/BitNodeUtils"; +import { UIEventEmitter, UIEventType } from "./UIEventEmitter"; import { exceptionAlert } from "../utils/helpers/exceptionAlert"; import { SpecialServers } from "../Server/data/SpecialServers"; import { ErrorModal } from "../ErrorHandling/ErrorModal"; @@ -98,19 +99,54 @@ const useStyles = makeStyles()((theme: Theme) => ({ const MAX_PAGES_IN_HISTORY = 10; +type RouterAction = ( + | { + type: "toPage"; + page: Page; + context?: PageContext; + } + | { + type: "back"; + } +) & { stackTrace: string | undefined }; + +/** + * When the main UI is not loaded, all router actions ("toPage" and "back") are stored in this array. After that, we + * will run them and show a warning popup. This queue is empty in a normal situation. If it has items, there are bugs + * that try to route the main UI when it's not loaded. + */ +const pendingRouterActions: RouterAction[] = []; + export let Router: IRouter = { page: () => { return Page.LoadingScreen; }, + /** + * This function is only called in ImportSave.tsx. That component is only used when the main UI shows Page.ImportSave, + * so it's impossible for this function to run before the main UI is loaded. If it happens, it's a fatal error. In + * that case, throwing an error is the only option. + */ allowRouting: () => { - throw new Error("Router called before initialization - allowRouting"); + throw new Error("Router.allowRouting() was called before initialization."); }, hidingMessages: () => true, - toPage: (page: Page) => { - throw new Error(`Router called before initialization - toPage(${page})`); + toPage: (page: Page, context?: PageContext) => { + const stackTrace = new Error().stack; + console.error("Router.toPage() was called before initialization.", page, context, stackTrace); + pendingRouterActions.push({ + type: "toPage", + page, + context, + stackTrace, + }); }, back: () => { - throw new Error("Router called before initialization - back"); + const stackTrace = new Error().stack; + console.error("Default Router.back() was called before initialization.", stackTrace); + pendingRouterActions.push({ + type: "back", + stackTrace, + }); }, }; @@ -141,7 +177,25 @@ export function GameRoot(): React.ReactElement { const { classes } = useStyles(); const [pages, setPages] = useState(() => [determineStartPage()]); - const pageWithContext = pages[0]; + let pageWithContext = pages[0]; + + /** + * Theoretically, this case cannot happen because of the check in Router.back(). Nevertheless, we should still check + * it. In the future, if we call "setPages" and remove items in the "pages" array without checking it properly, + * this case can still happen. + */ + if (pageWithContext === undefined) { + /** + * We have to delay showing the warning popup due to these reasons: + * - React will complain: "Warning: Cannot update a component (`AlertManager`) while rendering a different + * component (`GameRoot`)". + * - There is a potential problem in AlertManager.tsx. Please check the comment there for more information. + */ + setTimeout(() => { + exceptionAlert(new Error(`pageWithContext is undefined`)); + }, 1000); + pageWithContext = { page: Page.Terminal }; + } const setNextPage = (pageWithContext: PageWithContext) => setPages((prev) => { @@ -208,7 +262,16 @@ export function GameRoot(): React.ReactElement { setNextPage({ page, ...context } as PageWithContext); }, back: () => { - if (!allowRoutingCalls) return attemptedForbiddenRouting("back"); + if (!allowRoutingCalls) { + return attemptedForbiddenRouting("back"); + } + /** + * If something calls Router.back() when the "pages" array has only 1 item, that array will be empty when the UI + * is rerendered, and pageWithContext will be undefined. To avoid this problem, we return immediately in that case. + */ + if (pages.length === 1) { + return; + } setPages((pages) => pages.slice(1)); }, }; @@ -415,9 +478,37 @@ export function GameRoot(): React.ReactElement { mainPage = ; withSidebar = false; bypassGame = true; + break; } } + useEffect(() => { + if (pendingRouterActions.length > 0) { + // Run all pending actions and show a warning popup. + for (const action of pendingRouterActions) { + if (action.type === "toPage") { + if (isSimplePage(action.page)) { + Router.toPage(action.page); + } else { + Router.toPage(action.page, action.context ?? {}); + } + } else { + Router.back(); + } + } + exceptionAlert( + new Error( + `Router was used before the main UI is loaded. pendingRouterActions: ${JSON.stringify( + pendingRouterActions, + )}.`, + ), + ); + pendingRouterActions.length = 0; + } + // Emit an event to notify subscribers that the main UI is loaded. + UIEventEmitter.emit(UIEventType.MainUILoaded); + }, []); + return ( diff --git a/src/ui/UIEventEmitter.ts b/src/ui/UIEventEmitter.ts new file mode 100644 index 000000000..434228c1c --- /dev/null +++ b/src/ui/UIEventEmitter.ts @@ -0,0 +1,7 @@ +import { EventEmitter } from "../utils/EventEmitter"; + +export enum UIEventType { + MainUILoaded, +} + +export const UIEventEmitter = new EventEmitter(); diff --git a/src/utils/EventEmitter.ts b/src/utils/EventEmitter.ts index 9772dc588..96c3407a2 100644 --- a/src/utils/EventEmitter.ts +++ b/src/utils/EventEmitter.ts @@ -16,7 +16,7 @@ export class EventEmitter { } } - hasSubscibers(): boolean { + hasSubscribers(): boolean { return this.subscribers.size > 0; } } diff --git a/test/jest/Save.test.ts b/test/jest/Save.test.ts index 65f4f7f38..f7b7adc46 100644 --- a/test/jest/Save.test.ts +++ b/test/jest/Save.test.ts @@ -5,6 +5,7 @@ import { loadAllRunningScripts } from "../../src/NetscriptWorker"; import { Settings } from "../../src/Settings/Settings"; import { Player, setPlayer } from "../../src/Player"; import { PlayerObject } from "../../src/PersonObjects/Player/PlayerObject"; +import { UIEventEmitter, UIEventType } from "../../src/ui/UIEventEmitter"; jest.useFakeTimers(); // Direct tests of loading and saving. @@ -146,6 +147,7 @@ function loadStandardServers() { } }`); // Fix confused highlighting ` loadAllRunningScripts(); + UIEventEmitter.emit(UIEventType.MainUILoaded); } test("load/saveAllServers", () => {