diff --git a/src/ui/GameRoot.tsx b/src/ui/GameRoot.tsx index 64450d234..fcca21c98 100644 --- a/src/ui/GameRoot.tsx +++ b/src/ui/GameRoot.tsx @@ -81,6 +81,8 @@ import { AchievementsRoot } from "../Achievements/AchievementsRoot"; import { ErrorBoundary } from "./ErrorBoundary"; import { Settings } from "../Settings/Settings"; import { ThemeBrowser } from "../Themes/ui/ThemeBrowser"; +import { ImportSaveRoot } from "./React/ImportSaveRoot"; +import { BypassWrapper } from "./React/BypassWrapper"; const htmlLocation = location; @@ -199,6 +201,9 @@ export let Router: IRouter = { toThemeBrowser: () => { throw new Error("Router called before initialization"); }, + toImportSave: () => { + throw new Error("Router called before initialization"); + }, }; function determineStartPage(player: IPlayer): Page { @@ -228,6 +233,11 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme const [errorBoundaryKey, setErrorBoundaryKey] = useState(0); const [sidebarOpened, setSideBarOpened] = useState(Settings.IsSidebarOpened); + const [importString, setImportString] = useState(undefined as unknown as string); + const [importAutomatic, setImportAutomatic] = useState(false); + if (importString === undefined && page === Page.ImportSave) + throw new Error("Trying to go to a page without the proper setup"); + function resetErrorBoundary(): void { setErrorBoundaryKey(errorBoundaryKey + 1); } @@ -315,7 +325,12 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme }, toThemeBrowser: () => { setPage(Page.ThemeBrowser); - } + }, + toImportSave: (base64save: string, automatic = false) => { + setImportString(base64save); + setImportAutomatic(automatic); + setPage(Page.ImportSave); + }, }; useEffect(() => { @@ -332,11 +347,13 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme let mainPage = Cannot load; let withSidebar = true; let withPopups = true; + let bypassGame = false; switch (page) { case Page.Recovery: { mainPage = ; withSidebar = false; withPopups = false; + bypassGame = true; break; } case Page.BitVerse: { @@ -517,44 +534,62 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme mainPage = ; break; } + case Page.ImportSave: { + mainPage = ( + Router.toTerminal()} + /> + ); + withSidebar = false; + withPopups = false; + bypassGame = true; + } } return ( - - - {!ITutorial.isRunning ? ( - saveObject.saveGame()} killScripts={killAllScripts} /> + + + + {!ITutorial.isRunning ? ( + saveObject.saveGame()} killScripts={killAllScripts} /> + ) : ( + + )} + + {withSidebar ? ( + + { + setSideBarOpened(isOpened); + Settings.IsSidebarOpened = isOpened; + }} + /> + {mainPage} + ) : ( - - )} - - {withSidebar ? ( - - { - setSideBarOpened(isOpened); - Settings.IsSidebarOpened = isOpened; - }} /> {mainPage} - - ) : ( - {mainPage} - )} - - {withPopups && ( - <> - - - - - - - )} - + )} + + {withPopups && ( + <> + + + + + + + )} + + diff --git a/src/ui/React/BypassWrapper.tsx b/src/ui/React/BypassWrapper.tsx new file mode 100644 index 000000000..db793f225 --- /dev/null +++ b/src/ui/React/BypassWrapper.tsx @@ -0,0 +1,11 @@ +import React from "react"; + +interface IProps { + children: React.ReactNode; + content: React.ReactNode; +} + +export function BypassWrapper(props: IProps): React.ReactElement { + if (!props.content) return <>{props.children}; + return <>{props.content}; +} diff --git a/src/ui/React/ConfirmationModal.tsx b/src/ui/React/ConfirmationModal.tsx index e58b2fa57..b4de623d3 100644 --- a/src/ui/React/ConfirmationModal.tsx +++ b/src/ui/React/ConfirmationModal.tsx @@ -9,6 +9,7 @@ interface IProps { onClose: () => void; onConfirm: () => void; confirmationText: string | React.ReactNode; + additionalButton?: React.ReactNode; } export function ConfirmationModal(props: IProps): React.ReactElement { @@ -23,6 +24,7 @@ export function ConfirmationModal(props: IProps): React.ReactElement { > Confirm + {props.additionalButton && <>{props.additionalButton}} ); diff --git a/src/ui/React/GameOptionsRoot.tsx b/src/ui/React/GameOptionsRoot.tsx index 77d51e719..d327ade58 100644 --- a/src/ui/React/GameOptionsRoot.tsx +++ b/src/ui/React/GameOptionsRoot.tsx @@ -37,7 +37,8 @@ import { ThemeEditorButton } from "../../Themes/ui/ThemeEditorButton"; import { StyleEditorButton } from "../../Themes/ui/StyleEditorButton"; import { formatTime } from "../../utils/helpers/formatTime"; import { OptionSwitch } from "./OptionSwitch"; -import { saveObject } from "../../SaveObject"; +import { ImportData, saveObject } from "../../SaveObject"; +import { convertTimeMsToTimeElapsedString } from "../../utils/StringHelperFunctions"; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -58,12 +59,6 @@ interface IProps { softReset: () => void; } -interface ImportData { - base64: string; - parsed: any; - exportDate?: Date; -} - export function GameOptionsRoot(props: IProps): React.ReactElement { const classes = useStyles(); const importInput = useRef(null); @@ -125,7 +120,7 @@ export function GameOptionsRoot(props: IProps): React.ReactElement { try { const base64Save = await saveObject.getImportStringFromFile(event.target.files); const data = await saveObject.getImportDataFromString(base64Save); - setImportData(data) + setImportData(data); setImportSaveOpen(true); } catch (ex: any) { SnackbarEvents.emit(ex.toString(), "error", 5000); @@ -145,6 +140,13 @@ export function GameOptionsRoot(props: IProps): React.ReactElement { setImportData(null); } + function compareSaveGame(): void { + if (!importData) return; + props.router.toImportSave(importData.base64); + setImportSaveOpen(false); + setImportData(null); + } + return (
@@ -534,6 +536,7 @@ export function GameOptionsRoot(props: IProps): React.ReactElement { open={importSaveOpen} onClose={() => setImportSaveOpen(false)} onConfirm={() => confirmedImportGame()} + additionalButton={} confirmationText={ <> Importing a new game will completely wipe the current data! @@ -542,15 +545,24 @@ export function GameOptionsRoot(props: IProps): React.ReactElement { Make sure to have a backup of your current save file before importing.
The file you are attempting to import seems valid. -
-
- {importData?.exportDate && ( + {(importData?.playerData?.lastSave ?? 0) > 0 && ( <> - The export date of the save file is {importData?.exportDate.toString()}

+ The export date of the save file is{" "} + {new Date(importData?.playerData?.lastSave ?? 0).toLocaleString()} )} + {(importData?.playerData?.totalPlaytime ?? 0) > 0 && ( + <> +
+
+ Total play time of imported game:{" "} + {convertTimeMsToTimeElapsedString(importData?.playerData?.totalPlaytime ?? 0)} + + )} +
+
} /> diff --git a/src/ui/React/ImportSaveRoot.tsx b/src/ui/React/ImportSaveRoot.tsx new file mode 100644 index 000000000..fe7c888c0 --- /dev/null +++ b/src/ui/React/ImportSaveRoot.tsx @@ -0,0 +1,335 @@ +import React, { useEffect, useState } from "react"; + +import { + Paper, + Table, + TableHead, + TableRow, + TableBody, + TableContainer, + TableCell, + Typography, + Tooltip, + Box, + Button, + ButtonGroup, +} from "@mui/material"; + +import makeStyles from "@mui/styles/makeStyles"; +import createStyles from "@mui/styles/createStyles"; +import { Theme } from "@mui/material/styles"; + +import ThumbUpAlt from "@mui/icons-material/ThumbUpAlt"; +import ThumbDownAlt from "@mui/icons-material/ThumbDownAlt"; +import DirectionsRunIcon from "@mui/icons-material/DirectionsRun"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import WarningIcon from "@mui/icons-material/Warning"; + +import { ImportData, saveObject } from "../../SaveObject"; +import { Settings } from "../../Settings/Settings"; +import { convertTimeMsToTimeElapsedString } from "../../utils/StringHelperFunctions"; +import { numeralWrapper } from "../numeralFormat"; +import { ConfirmationModal } from "./ConfirmationModal"; + + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + padding: theme.spacing(2), + maxWidth: "1000px", + + "& .MuiTable-root": { + "& .MuiTableCell-root": { + borderBottom: `1px solid ${Settings.theme.welllight}`, + }, + + "& .MuiTableHead-root .MuiTableRow-root": { + backgroundColor: Settings.theme.backgroundsecondary, + + "& .MuiTableCell-root": { + color: Settings.theme.primary, + fontWeight: "bold", + }, + }, + + "& .MuiTableBody-root": { + "& .MuiTableRow-root:nth-of-type(odd)": { + backgroundColor: Settings.theme.well, + + "& .MuiTableCell-root": { + color: Settings.theme.primarylight, + }, + }, + "& .MuiTableRow-root:nth-of-type(even)": { + backgroundColor: Settings.theme.backgroundsecondary, + + "& .MuiTableCell-root": { + color: Settings.theme.primarylight, + }, + }, + }, + }, + }, + }), +); + +function ComparisonIcon({ isBetter }: { isBetter: boolean }): JSX.Element { + if (isBetter) { + return ( + + Imported value is larger! + + } + > + + + ); + } else { + return ( + + Imported value is smaller! + + } + > + + + ); + } +} + +export interface ImportSaveProps { + importString: string; + automatic: boolean; + onReturning: () => void; +} + +let initialAutosave = 0; + +export function ImportSaveRoot({ importString, automatic, onReturning }: ImportSaveProps): JSX.Element { + const classes = useStyles(); + const [importData, setImportData] = useState(); + const [currentData, setCurrentData] = useState(); + const [importModalOpen, setImportModalOpen] = useState(false); + + function handleGoBack(): void { + Settings.AutosaveInterval = initialAutosave; + onReturning(); + } + + async function handleImport(): Promise { + await saveObject.importGame(importString, true); + } + + useEffect(() => { + // We want to disable autosave while we're in this mode + initialAutosave = Settings.AutosaveInterval; + Settings.AutosaveInterval = 0; + }, []); + + useEffect(() => { + async function fetchData(): Promise { + const dataBeingImported = await saveObject.getImportDataFromString(importString); + const dataCurrentlyInGame = await saveObject.getImportDataFromString(saveObject.getSaveString(true)); + + setImportData(dataBeingImported); + setCurrentData(dataCurrentlyInGame); + + return Promise.resolve(); + } + if (importString) fetchData(); + }, [importString]); + + if (!importData || !currentData) return <>; + return ( + + + Import Save Comparison + + {automatic && ( + + We've found a NEWER save that you may want to use instead. + + )} + + Your current game's data is on the left and the data that will be imported is on the right. +
+ Please double check everything is fine before proceeding! +
+ + + + + + Current Game + Being Imported + + + + + + + Game Identifier + {currentData.playerData?.identifier ?? "n/a"} + {importData.playerData?.identifier ?? "n/a"} + + {importData.playerData?.identifier !== currentData.playerData?.identifier && ( + + + + )} + + + + Playtime + {convertTimeMsToTimeElapsedString(currentData.playerData?.totalPlaytime ?? 0)} + {convertTimeMsToTimeElapsedString(importData.playerData?.totalPlaytime ?? 0)} + + {importData.playerData?.totalPlaytime !== currentData.playerData?.totalPlaytime && ( + (currentData.playerData?.totalPlaytime ?? 0) + } + /> + )} + + + + + Saved On + {new Date(currentData.playerData?.lastSave ?? 0).toLocaleString()} + {new Date(importData.playerData?.lastSave ?? 0).toLocaleString()} + + {importData.playerData?.lastSave !== currentData.playerData?.lastSave && ( + (currentData.playerData?.lastSave ?? 0)} + /> + )} + + + + + Money + {numeralWrapper.formatMoney(currentData.playerData?.money ?? 0)} + {numeralWrapper.formatMoney(importData.playerData?.money ?? 0)} + + {importData.playerData?.money !== currentData.playerData?.money && ( + (currentData.playerData?.money ?? 0)} + /> + )} + + + + + Hacking + {numeralWrapper.formatSkill(currentData.playerData?.hacking ?? 0)} + {numeralWrapper.formatSkill(importData.playerData?.hacking ?? 0)} + + {importData.playerData?.hacking !== currentData.playerData?.hacking && ( + (currentData.playerData?.hacking ?? 0)} + /> + )} + + + + + Augmentations + {currentData.playerData?.augmentations} + {importData.playerData?.augmentations} + + {importData.playerData?.augmentations !== currentData.playerData?.augmentations && ( + (currentData.playerData?.augmentations ?? 0) + } + /> + )} + + + + + Factions + {currentData.playerData?.factions} + {importData.playerData?.factions} + + {importData.playerData?.factions !== currentData.playerData?.factions && ( + (currentData.playerData?.factions ?? 0)} + /> + )} + + + + Achievements + {currentData.playerData?.achievements} + {importData.playerData?.achievements} + + {importData.playerData?.achievements !== currentData.playerData?.achievements && ( + (currentData.playerData?.achievements ?? 0)} + /> + )} + + + + + Source Files + {currentData.playerData?.sourceFiles} + {importData.playerData?.sourceFiles} + + {importData.playerData?.sourceFiles !== currentData.playerData?.sourceFiles && ( + (currentData.playerData?.sourceFiles ?? 0)} + /> + )} + + + + + BitNode + + {currentData.playerData?.bitNode}-{currentData.playerData?.bitNodeLevel} + + + {importData.playerData?.bitNode}-{importData.playerData?.bitNodeLevel} + + + + +
+
+ + + + + + + setImportModalOpen(false)} + onConfirm={handleImport} + confirmationText={ + <> + Importing new save game data will completely wipe the current game data! +
+ + } + /> +
+
+ ); +} diff --git a/src/ui/Router.ts b/src/ui/Router.ts index 7ebeb31d8..0b9bf2739 100644 --- a/src/ui/Router.ts +++ b/src/ui/Router.ts @@ -38,6 +38,7 @@ export enum Page { Recovery, Achievements, ThemeBrowser, + ImportSave, } export interface ScriptEditorRouteOptions { @@ -84,4 +85,5 @@ export interface IRouter { toStaneksGift(): void; toAchievements(): void; toThemeBrowser(): void; + toImportSave(base64Save: string, automatic?: boolean): void; }