BUGFIX: Running scripts may be loaded before main UI (#1726)

This commit is contained in:
catloversg
2025-07-06 08:10:51 +07:00
committed by GitHub
parent 970292bedb
commit 9b59fcbb5f
7 changed files with 159 additions and 39 deletions

View File

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

View File

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

View File

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

View File

@@ -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<ComplexPage>;
}
| {
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<ComplexPage>) => {
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<PageWithContext[]>(() => [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 = <ImportSave saveData={pageWithContext.saveData} automatic={!!pageWithContext.automatic} />;
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 (
<MathJaxContext version={3} src={__webpack_public_path__ + "mathjax/tex-chtml.js"}>
<ErrorBoundary key={errorBoundaryKey} softReset={softReset}>

7
src/ui/UIEventEmitter.ts Normal file
View File

@@ -0,0 +1,7 @@
import { EventEmitter } from "../utils/EventEmitter";
export enum UIEventType {
MainUILoaded,
}
export const UIEventEmitter = new EventEmitter<UIEventType[]>();

View File

@@ -16,7 +16,7 @@ export class EventEmitter<T extends any[]> {
}
}
hasSubscibers(): boolean {
hasSubscribers(): boolean {
return this.subscribers.size > 0;
}
}

View File

@@ -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", () => {