diff --git a/src/Server/AllServers.ts b/src/Server/AllServers.ts index 4e2d40356..c44bf594b 100644 --- a/src/Server/AllServers.ts +++ b/src/Server/AllServers.ts @@ -219,6 +219,9 @@ export function prestigeAllServers(): void { export function loadAllServers(saveString: string): void { const allServersData: unknown = JSON.parse(saveString, Reviver); assertObject(allServersData); + if (Object.keys(allServersData).length === 0) { + throw new Error("Server list is empty."); + } for (const [serverName, server] of Object.entries(allServersData)) { if (!(server instanceof Server) && !(server instanceof HacknetServer)) { throw new Error(`Server ${serverName} is not an instance of Server or HacknetServer.`); diff --git a/src/db.ts b/src/db.ts index 2c3e9a793..aee9049d3 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,4 +1,4 @@ -import { SaveData } from "./types"; +import type { SaveData } from "./types"; function getDB(): Promise { return new Promise((resolve, reject) => { diff --git a/src/engine.tsx b/src/engine.tsx index 84f1efb61..35d453acc 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -48,6 +48,7 @@ import { Go } from "./Go/Go"; import { EventEmitter } from "./utils/EventEmitter"; import { Companies } from "./Company/Companies"; import { resetGoPromises } from "./Go/boardAnalysis/goAI"; +import { getRecordEntries } from "./Types/Record"; declare global { // This property is only available in the dev build @@ -67,30 +68,8 @@ declare global { export const GameCycleEvents = new EventEmitter<[]>(); /** Game engine. Handles the main game loop. */ -const Engine: { - _lastUpdate: number; - updateGame: (numCycles?: number) => void; - Counters: { - [key: string]: number | undefined; - autoSaveCounter: number; - updateSkillLevelsCounter: number; - updateDisplays: number; - updateDisplaysLong: number; - updateActiveScriptsDisplay: number; - createProgramNotifications: number; - augmentationsNotifications: number; - checkFactionInvitations: number; - passiveFactionGrowth: number; - messages: number; - mechanicProcess: number; - contractGeneration: number; - achievementsCounter: number; - }; - decrementAllCounters: (numCycles?: number) => void; - checkCounters: () => void; - load: (saveData: SaveData) => Promise; - start: () => void; -} = { +const Engine = { + isRunning: false, // Time variables (milliseconds unix epoch time) _lastUpdate: new Date().getTime(), updateGame: function (numCycles = 1) { @@ -170,7 +149,7 @@ const Engine: { }, decrementAllCounters: function (numCycles = 1) { - for (const [counterName, counter] of Object.entries(Engine.Counters)) { + for (const [counterName, counter] of getRecordEntries(Engine.Counters)) { if (counter === undefined) { exceptionAlert(new Error(`counter value is undefined. counterName: ${counterName}.`), true); continue; @@ -246,7 +225,7 @@ const Engine: { } }, - load: async function (saveData) { + load: async function (saveData: SaveData) { startExploits(); setupUncaughtPromiseHandler(); // Source files must be initialized early because save-game translation in @@ -420,6 +399,7 @@ const Engine: { }, start: function () { + this.isRunning = true; // Get time difference const _thisUpdate = new Date().getTime(); let diff = _thisUpdate - Engine._lastUpdate; diff --git a/src/ui/ErrorBoundary.tsx b/src/ui/ErrorBoundary.tsx index 6ee77dd6e..a3e542d12 100644 --- a/src/ui/ErrorBoundary.tsx +++ b/src/ui/ErrorBoundary.tsx @@ -35,6 +35,19 @@ export class ErrorBoundary extends React.Component Set hasError and error + * - render() -> Render RecoveryRoot with errorData1, which does not contain errorInfo and page + * - componentDidCatch() -> Set errorInfo and page + * - render() -> Render RecoveryRoot with errorData2, which contains errorInfo and page + * + * This means that if we use useEffect(()=>{}, [errorData]) in RecoveryRoot, that hook will be called twice with 2 + * different errorData. The second errorData, which contains errorInfo and page, is the "final" value that is shown on + * the recovery screen. + */ render(): React.ReactNode { if (this.state.hasError) { let errorData: IErrorData | undefined; diff --git a/src/ui/GameRoot.tsx b/src/ui/GameRoot.tsx index 67bdfa7ed..7b93176c6 100644 --- a/src/ui/GameRoot.tsx +++ b/src/ui/GameRoot.tsx @@ -61,7 +61,7 @@ import { AlertManager } from "./React/AlertManager"; import { PromptManager } from "./React/PromptManager"; import { FactionInvitationManager } from "../Faction/ui/FactionInvitationManager"; import { calculateAchievements } from "../Achievements/Achievements"; -import { RecoveryMode, RecoveryRoot } from "./React/RecoveryRoot"; +import { ActivateRecoveryMode, RecoveryMode, RecoveryRoot } from "./React/RecoveryRoot"; import { AchievementsRoot } from "../Achievements/AchievementsRoot"; import { ErrorBoundary } from "./ErrorBoundary"; import { ThemeBrowser } from "../Themes/ui/ThemeBrowser"; @@ -116,9 +116,18 @@ function determineStartPage(): PageWithContext { if (RecoveryMode) { return { page: Page.Recovery }; } - if (isBitNodeFinished()) { - // Go to BitVerse UI without animation. - return { page: Page.BitVerse, flume: false, quick: true }; + /** + * If the save data contains the server list, but WD data is invalid, isBitNodeFinished() will throw an error, and the + * main UI will show a black screen instead of the recovery screen. + */ + try { + if (isBitNodeFinished()) { + // Go to BitVerse UI without animation. + return { page: Page.BitVerse, flume: false, quick: true }; + } + } catch (error) { + ActivateRecoveryMode(error); + return { page: Page.Recovery }; } if (Player.currentWork !== null) { return { page: Page.Work }; diff --git a/src/ui/LoadingScreen.tsx b/src/ui/LoadingScreen.tsx index 1f5dd06e5..ad77f59c9 100644 --- a/src/ui/LoadingScreen.tsx +++ b/src/ui/LoadingScreen.tsx @@ -40,10 +40,9 @@ export function LoadingScreen(): React.ReactElement { pushGameReady(); setLoaded(true); }) - .catch(async (error) => { + .catch((error) => { console.error(error); ActivateRecoveryMode(error); - await Engine.load(""); setLoaded(true); }); }, []); diff --git a/src/ui/React/RecoveryRoot.tsx b/src/ui/React/RecoveryRoot.tsx index 6d336248b..2ce1af45c 100644 --- a/src/ui/React/RecoveryRoot.tsx +++ b/src/ui/React/RecoveryRoot.tsx @@ -14,6 +14,8 @@ import GitHubIcon from "@mui/icons-material/GitHub"; import { isBinaryFormat } from "../../../electron/saveDataBinaryFormat"; import { InvalidSaveData, UnsupportedSaveData } from "../../utils/SaveDataUtils"; import { downloadContentAsFile } from "../../utils/FileUtils"; +import { debounce } from "lodash"; +import { Engine } from "../../engine"; export let RecoveryMode = false; let sourceError: unknown; @@ -32,9 +34,8 @@ interface IProps { function exportSaveFile(): void { load() .then((content) => { - const epochTime = Math.round(Date.now() / 1000); const extension = isBinaryFormat(content) ? "json.gz" : "json"; - const filename = `RECOVERY_BITBURNER_${epochTime}.${extension}`; + const filename = `RECOVERY_BITBURNER_${Date.now()}.${extension}`; downloadContentAsFile(content, filename); }) .catch((err) => { @@ -42,6 +43,25 @@ function exportSaveFile(): void { }); } +const debouncedExportSaveFile = debounce(exportSaveFile, 1000); + +const debouncedExportCrashReport = debounce((crashReport: unknown) => { + const content = typeof crashReport === "object" ? JSON.stringify(crashReport) : String(crashReport); + downloadContentAsFile(content, `CRASH_REPORT_BITBURNER_${Date.now()}.txt`); +}, 2000); + +/** + * The recovery screen can be activated in 2 ways: + * - Call ActivateRecoveryMode() [1]. + * - Before loading the save data: An error is thrown in src\ui\LoadingScreen.tsx (e.g., cannot load SWC wasm files, + * cannot access IndexedDB and load the save data, Engine.load() throws an error). + * - isBitNodeFinished() throws an error in src\ui\GameRoot.tsx. + * - ErrorBoundary [2]: After loading the save data and GameRoot is rendered, an error is thrown anywhere else. + * + * [1]: errorData is undefined and sourceError, which is the error thrown in LoadingScreen.tsx, is set via ActivateRecoveryMode(). + * [2]: RecoveryRoot is rendered twice with 2 different errorData. For more information, please check the comment in + * src\ui\ErrorBoundary.tsx. + */ export function RecoveryRoot({ softReset, errorData, resetError }: IProps): React.ReactElement { function recover(): void { if (resetError) resetError(); @@ -51,16 +71,28 @@ export function RecoveryRoot({ softReset, errorData, resetError }: IProps): Reac } Settings.AutosaveInterval = 0; - // The architecture around RecoveryRoot is awkward, and it can be invoked in - // a number of ways. If we are invoked via a save error, sourceError will be set - // and we won't have decoded the information into errorData. + // This happens in [1] mentioned above. errorData is undefined, so we need to parse sourceError to get errorData. if (errorData == null && sourceError) { errorData = getErrorForDisplay(sourceError, undefined, Page.LoadingScreen); } useEffect(() => { - exportSaveFile(); - }, []); + // This hook is called twice in [2], so we need to debounce exportSaveFile(). + debouncedExportSaveFile(); + + /** + * This hook can be called with 3 types of errorData: + * - In [1]: errorData.metadata.page is Page.LoadingScreen + * - In [2]: + * - First render: errorData.metadata.errorInfo is undefined + * - Second render: errorData.metadata.errorInfo contains componentStack + * + * The following check makes sure that we do not write the crash report in the "first render" of [2]. + */ + if (errorData && (errorData.metadata.errorInfo || errorData.metadata.page === Page.LoadingScreen)) { + debouncedExportCrashReport(errorData.body); + } + }, [errorData]); let instructions; if (sourceError instanceof UnsupportedSaveData) { @@ -88,11 +120,18 @@ export function RecoveryRoot({ softReset, errorData, resetError }: IProps): Reac Post in the #bug-report channel on Discord. - Please include your save file. + Please include your save file and the crash report. ); } + /** + * If Engine.isRunning is false, it means that the loading process in src\ui\LoadingScreen.tsx failed, and the loaded + * data is either empty or corrupted (partially or fully). In this case, there is no reason to allow the player to + * disable the recovery mode and go back to the main UI. + */ + const canDisableRecoveryMode = Engine.isRunning; + return ( RECOVERY MODE ACTIVATED @@ -114,13 +153,19 @@ export function RecoveryRoot({ softReset, errorData, resetError }: IProps): Reac

- You can disable the recovery mode, but the game may not work correctly. + {canDisableRecoveryMode && ( + + You can disable the recovery mode, but the game may not work correctly, and your save data may be corrupted. + + )} - - - + {canDisableRecoveryMode && ( + + + + )} diff --git a/src/utils/helpers/exceptionAlert.tsx b/src/utils/helpers/exceptionAlert.tsx index 77c7dd2e1..a5bcdc210 100644 --- a/src/utils/helpers/exceptionAlert.tsx +++ b/src/utils/helpers/exceptionAlert.tsx @@ -4,6 +4,29 @@ import Typography from "@mui/material/Typography"; import { parseUnknownError } from "../ErrorHelper"; import { cyrb53 } from "../HashUtils"; import { commitHash } from "./commitHash"; +import { Player } from "@player"; +import type { TextFilePath } from "../../Paths/TextFilePath"; + +const crashReports = new Set(); + +export function writeCrashReportToHome(errorData: unknown): void { + // Put all code in try-catch to make sure that this function never crashes. + try { + const crashReport = typeof errorData === "object" ? JSON.stringify(errorData) : String(errorData); + /** + * Crash reports are written to the home server, and they increase the size of save data. Therefore, it's best to + * ensure that we don't write duplicate reports. It may happen if a bug triggers exceptionAlert, and we forgot + * passing showOnlyOnce = true. + */ + if (crashReports.has(crashReport)) { + return; + } + Player.getHomeComputer().writeToTextFile(`CRASH_REPORT_${Date.now()}.txt` as TextFilePath, crashReport); + crashReports.add(crashReport); + } catch (error) { + console.error(error); + } +} const errorSet = new Set(); @@ -29,6 +52,13 @@ export function exceptionAlert(error: unknown, showOnlyOnce = false): void { errorSet.add(errorId); } + const commitId = commitHash(); + writeCrashReportToHome({ + errorData, + commitId, + userAgent: navigator.userAgent, + }); + dialogBoxCreate( <> Caught an exception: {errorData.errorAsString} @@ -54,7 +84,7 @@ export function exceptionAlert(error: unknown, showOnlyOnce = false): void { )}
- Commit: {commitHash()} + Commit: {commitId}
UserAgent: {navigator.userAgent}