Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
wants to merge 6 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 42 additions & 26 deletions src/NetscriptWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import { CompleteRunOptions, getRunningScriptsByArgs } from "./Netscript/Netscri
import { handleUnknownError } from "./utils/ErrorHandler";
import { isLegacyScript, legacyScriptExtension, resolveScriptFilePath, ScriptFilePath } from "./Paths/ScriptFilePath";
import { root } from "./Paths/Directory";
import { Player } from "./Player";
import { UIEventEmitter, UIEventType } from "./ui/UIEventEmitter";
import { exceptionAlert } from "./utils/helpers/exceptionAlert";

export const NetscriptPorts = new Map<PortNumber, Port>();
Expand Down Expand Up @@ -414,36 +416,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 runningScript of rsList) {
startWorkerScript(runningScript, server);
scriptCalculateOfflineProduction(runningScript);
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);
}
}
}
});
}

/** Run a script from inside another script (run(), exec(), spawn(), etc.) */
Expand Down
10 changes: 7 additions & 3 deletions src/Script/ScriptHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -55,7 +59,7 @@ export function scriptCalculateOfflineProduction(runningScript: RunningScript):
Player.gainHackingExp(expGain);

const moneyGain =
(runningScript.onlineMoneyMade / Player.playtimeSinceLastAug) * timePassed * CONSTANTS.OfflineHackingIncome;
(runningScript.onlineMoneyMade / playerPlaytimeSinceLastAug) * timePassed * CONSTANTS.OfflineHackingIncome;
// money is given to player during engine load
Player.scriptProdSinceLastAug += moneyGain;

Expand Down
4 changes: 2 additions & 2 deletions src/engine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,10 +279,10 @@ const Engine: {
const offlineHackingIncome =
(Player.moneySourceA.hacking / Player.playtimeSinceLastAug) * timeOffline * CONSTANTS.OfflineHackingIncome;
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);
Expand Down Expand Up @@ -432,7 +432,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();
});
Expand Down
105 changes: 98 additions & 7 deletions src/ui/GameRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { GetAllServers } from "../Server/AllServers";
import { StockMarket } from "../StockMarket/StockMarket";

import type { PageWithContext, IRouter, ComplexPage, PageContext } 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";
Expand Down Expand Up @@ -75,6 +75,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";

const htmlLocation = location;
Expand All @@ -94,19 +95,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,
});
},
};

Expand All @@ -128,7 +164,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) => {
Expand Down Expand Up @@ -195,7 +249,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 void this problem, we return immediately in that case.
*/
if (pages.length === 1) {
return;
}
setPages((pages) => pages.slice(1));
},
};
Expand Down Expand Up @@ -390,9 +453,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}>
Expand Down
7 changes: 7 additions & 0 deletions src/ui/UIEventEmitter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { EventEmitter } from "../utils/EventEmitter";

export enum UIEventType {
MainUILoaded,
}

export const UIEventEmitter = new EventEmitter<UIEventType[]>();
2 changes: 1 addition & 1 deletion src/utils/EventEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export class EventEmitter<T extends any[]> {
}
}

hasSubscibers(): boolean {
hasSubscribers(): boolean {
return this.subscribers.size > 0;
}
}
2 changes: 2 additions & 0 deletions test/jest/Save.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -146,6 +147,7 @@ function loadStandardServers() {
}
}`); // Fix confused highlighting `
loadAllRunningScripts();
UIEventEmitter.emit(UIEventType.MainUILoaded);
}

test("load/saveAllServers", () => {
Expand Down
Loading