From 26432082e2c81c871bf37e8037237f66b8567e82 Mon Sep 17 00:00:00 2001 From: Martin Fournier Date: Thu, 20 Jan 2022 14:55:28 -0500 Subject: [PATCH] Add an import save comparison page This adds a new page reachable from the import save file options menu. It shows the difference between the current save and the data that is being imported, for confirmation. Includes an "automatic" variant, which has different wording for when Electron decides it has access to a newer version of the game. While in this screen, the autosave is disabled. This also adds a new BypassWrapper component around the game's tree. It allows for content to be displayed without rendering the nested pages (import, recovery). This prevents player scripts from messing with the screen. --- src/ui/GameRoot.tsx | 97 ++++++--- src/ui/React/BypassWrapper.tsx | 11 + src/ui/React/ConfirmationModal.tsx | 2 + src/ui/React/GameOptionsRoot.tsx | 36 ++-- src/ui/React/ImportSaveRoot.tsx | 335 +++++++++++++++++++++++++++++ src/ui/Router.ts | 2 + 6 files changed, 440 insertions(+), 43 deletions(-) create mode 100644 src/ui/React/BypassWrapper.tsx create mode 100644 src/ui/React/ImportSaveRoot.tsx 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; }