From 974344545dc2d8c61b974c27513fbcd2e09b08f0 Mon Sep 17 00:00:00 2001 From: Martin Fournier Date: Tue, 18 Jan 2022 11:53:30 -0500 Subject: [PATCH] Move import save functionality into SaveObject Adds a lot of metadata to be able to easily compare the new save to the current player. - Adds lastSaved & identifier to the PlayerObject - Includes a hash method used to generate an identifier --- src/PersonObjects/Player/PlayerObject.ts | 18 ++- src/SaveObject.tsx | 149 ++++++++++++++++++++--- src/ui/React/GameOptionsRoot.tsx | 89 +++----------- src/utils/StringHelperFunctions.ts | 29 ++++- 4 files changed, 198 insertions(+), 87 deletions(-) diff --git a/src/PersonObjects/Player/PlayerObject.ts b/src/PersonObjects/Player/PlayerObject.ts index c03569225..3a50f68a3 100644 --- a/src/PersonObjects/Player/PlayerObject.ts +++ b/src/PersonObjects/Player/PlayerObject.ts @@ -35,7 +35,9 @@ import { CityName } from "../../Locations/data/CityNames"; import { MoneySourceTracker } from "../../utils/MoneySourceTracker"; import { Reviver, Generic_toJSON, Generic_fromJSON } from "../../utils/JSONReviver"; import { ISkillProgress } from "../formulas/skill"; -import { PlayerAchievement } from '../../Achievements/Achievements'; +import { PlayerAchievement } from "../../Achievements/Achievements"; +import { cyrb53 } from "../../utils/StringHelperFunctions"; +import { getRandomInt } from "../../utils/helpers/getRandomInt"; export class PlayerObject implements IPlayer { // Class members @@ -78,7 +80,9 @@ export class PlayerObject implements IPlayer { exploits: Exploit[]; achievements: PlayerAchievement[]; terminalCommandHistory: string[]; + identifier: string; lastUpdate: number; + lastSave: number; totalPlaytime: number; // Stats @@ -460,7 +464,9 @@ export class PlayerObject implements IPlayer { //Used to store the last update time. this.lastUpdate = 0; + this.lastSave = 0; this.totalPlaytime = 0; + this.playtimeSinceLastAug = 0; this.playtimeSinceLastBitnode = 0; @@ -474,6 +480,16 @@ export class PlayerObject implements IPlayer { this.achievements = []; this.terminalCommandHistory = []; + // Let's get a hash of some semi-random stuff so we have something unique. + this.identifier = cyrb53( + "I-" + + new Date().getTime() + + navigator.userAgent + + window.innerWidth + + window.innerHeight + + getRandomInt(100, 999), + ); + this.init = generalMethods.init; this.prestigeAugmentation = generalMethods.prestigeAugmentation; this.prestigeSourceFile = generalMethods.prestigeSourceFile; diff --git a/src/SaveObject.tsx b/src/SaveObject.tsx index eaa997269..83e7f0603 100755 --- a/src/SaveObject.tsx +++ b/src/SaveObject.tsx @@ -23,11 +23,35 @@ import { AugmentationNames } from "./Augmentation/data/AugmentationNames"; import { PlayerOwnedAugmentation } from "./Augmentation/PlayerOwnedAugmentation"; import { LocationName } from "./Locations/data/LocationNames"; import { SxProps } from "@mui/system"; +import { PlayerObject } from "./PersonObjects/Player/PlayerObject"; /* SaveObject.js * Defines the object used to save/load games */ +export interface ImportData { + base64: string; + parsed: any; + playerData?: ImportPlayerData; +} + +export interface ImportPlayerData { + identifier: string; + lastSave: number; + totalPlaytime: number; + + money: number; + hacking: number; + + augmentations: number; + factions: number; + achievements: number; + + bitNode: number; + bitNodeLevel: number; + sourceFiles: number; +} + class BitburnerSaveObject { PlayerSave = ""; AllServersSave = ""; @@ -42,7 +66,6 @@ class BitburnerSaveObject { AllGangsSave = ""; LastExportBonus = ""; StaneksGiftSave = ""; - SaveTimestamp = ""; getSaveString(excludeRunningScripts = false): string { this.PlayerSave = JSON.stringify(Player); @@ -58,7 +81,6 @@ class BitburnerSaveObject { this.VersionSave = JSON.stringify(CONSTANTS.VersionNumber); this.LastExportBonus = JSON.stringify(ExportBonus.LastExportBonus); this.StaneksGiftSave = JSON.stringify(staneksGift); - this.SaveTimestamp = new Date().getTime().toString(); if (Player.inGang()) { this.AllGangsSave = JSON.stringify(AllGangs); @@ -68,28 +90,125 @@ class BitburnerSaveObject { return saveString; } - saveGame(emitToastEvent = true): void { + saveGame(emitToastEvent = true): Promise { + Player.lastSave = new Date().getTime(); const saveString = this.getSaveString(Settings.ExcludeRunningScriptsFromSave); + return new Promise((resolve, reject) => { + save(saveString) + .then(() => { + if (emitToastEvent) { + SnackbarEvents.emit("Game Saved!", "info", 2000); + } + return resolve(); + }) + .catch((err) => { + console.error(err); + return reject(); + }); + }); + } - save(saveString) - .then(() => { - if (emitToastEvent) { - SnackbarEvents.emit("Game Saved!", "info", 2000); - } - }) - .catch((err) => console.error(err)); + getSaveFileName(isRecovery = false): string { + // Save file name is based on current timestamp and BitNode + const epochTime = Math.round(Date.now() / 1000); + const bn = Player.bitNodeN; + let filename = `bitburnerSave_${epochTime}_BN${bn}x${SourceFileFlags[bn]}.json`; + if (isRecovery) filename = "RECOVERY" + filename; + return filename; } exportGame(): void { const saveString = this.getSaveString(Settings.ExcludeRunningScriptsFromSave); - - // Save file name is based on current timestamp and BitNode - const epochTime = Math.round(Date.now() / 1000); - const bn = Player.bitNodeN; - const filename = `bitburnerSave_${epochTime}_BN${bn}x${SourceFileFlags[bn]}.json`; + const filename = this.getSaveFileName(); download(filename, saveString); } + importGame(base64Save: string, reload = true): Promise { + if (!base64Save || base64Save === "") throw new Error("Invalid import string"); + return save(base64Save).then(() => { + if (reload) setTimeout(() => location.reload(), 1000); + return Promise.resolve(); + }); + } + + getImportStringFromFile(files: FileList | null): Promise { + if (files === null) return Promise.reject(new Error("No file selected")); + const file = files[0]; + if (!file) return Promise.reject(new Error("Invalid file selected")); + + const reader = new FileReader(); + const promise: Promise = new Promise((resolve, reject) => { + reader.onload = function (this: FileReader, e: ProgressEvent) { + const target = e.target; + if (target === null) { + return reject(new Error("Error importing file")); + } + const result = target.result; + if (typeof result !== "string" || result === null) { + return reject(new Error("FileReader event was not type string")); + } + const contents = result; + resolve(contents); + }; + }); + reader.readAsText(file); + return promise; + } + + async getImportDataFromString(base64Save: string): Promise { + if (!base64Save || base64Save === "") throw new Error("Invalid import string"); + + let newSave; + try { + newSave = window.atob(base64Save); + newSave = newSave.trim(); + } catch (error) { + console.error(error); // We'll handle below + } + + if (!newSave || newSave === "") { + return Promise.reject(new Error("Save game had not content or was not base64 encoded")); + } + + let parsedSave; + try { + parsedSave = JSON.parse(newSave); + } catch (error) { + console.log(error); // We'll handle below + } + + if (!parsedSave || parsedSave.ctor !== "BitburnerSaveObject" || !parsedSave.data) { + return Promise.reject(new Error("Save game did not seem valid")); + } + + const data: ImportData = { + base64: base64Save, + parsed: parsedSave, + }; + + const importedPlayer = PlayerObject.fromJSON(JSON.parse(parsedSave.data.PlayerSave)); + + const playerData: ImportPlayerData = { + identifier: importedPlayer.identifier, + lastSave: importedPlayer.lastSave, + totalPlaytime: importedPlayer.totalPlaytime, + + money: importedPlayer.money, + hacking: importedPlayer.hacking, + + augmentations: importedPlayer.augmentations?.reduce((total, current) => (total += current.level), 0) ?? 0, + factions: importedPlayer.factions?.length ?? 0, + achievements: importedPlayer.achievements?.length ?? 0, + + bitNode: importedPlayer.bitNodeN, + bitNodeLevel: importedPlayer.sourceFileLvl(Player.bitNodeN) + 1, + sourceFiles: importedPlayer.sourceFiles?.reduce((total, current) => (total += current.lvl), 0) ?? 0, + }; + + data.playerData = playerData; + return Promise.resolve(data); + } + toJSON(): any { return Generic_toJSON("BitburnerSaveObject", this); } diff --git a/src/ui/React/GameOptionsRoot.tsx b/src/ui/React/GameOptionsRoot.tsx index 03b75d060..77d51e719 100644 --- a/src/ui/React/GameOptionsRoot.tsx +++ b/src/ui/React/GameOptionsRoot.tsx @@ -25,20 +25,19 @@ import SaveIcon from "@mui/icons-material/Save"; import PaletteIcon from '@mui/icons-material/Palette'; import { FileDiagnosticModal } from "../../Diagnostic/FileDiagnosticModal"; -import { dialogBoxCreate } from "./DialogBox"; import { ConfirmationModal } from "./ConfirmationModal"; import { SnackbarEvents } from "./Snackbar"; import { Settings } from "../../Settings/Settings"; -import { save } from "../../db"; -import { formatTime } from "../../utils/helpers/formatTime"; -import { OptionSwitch } from "./OptionSwitch"; import { DeleteGameButton } from "./DeleteGameButton"; import { SoftResetButton } from "./SoftResetButton"; import { IRouter } from "../Router"; 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"; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -122,78 +121,28 @@ export function GameOptionsRoot(props: IProps): React.ReactElement { ii.click(); } - function onImport(event: React.ChangeEvent): void { - const files = event.target.files; - if (files === null) return; - const file = files[0]; - if (!file) { - dialogBoxCreate("Invalid file selected"); - return; - } - - const reader = new FileReader(); - reader.onload = function (this: FileReader, e: ProgressEvent) { - const target = e.target; - if (target === null) { - console.error("error importing file"); - return; - } - const result = target.result; - if (typeof result !== "string" || result === null) { - console.error("FileReader event was not type string"); - return; - } - const contents = result; - - let newSave; - try { - newSave = window.atob(contents); - newSave = newSave.trim(); - } catch (error) { - console.log(error); // We'll handle below - } - - if (!newSave || newSave === "") { - SnackbarEvents.emit("Save game had not content or was not base64 encoded", "error", 5000); - return; - } - - let parsedSave; - try { - parsedSave = JSON.parse(newSave); - } catch (error) { - console.log(error); // We'll handle below - } - - if (!parsedSave || parsedSave.ctor !== "BitburnerSaveObject" || !parsedSave.data) { - SnackbarEvents.emit("Save game did not seem valid", "error", 5000); - return; - } - - const data: ImportData = { - base64: contents, - parsed: parsedSave, - }; - - const timestamp = parsedSave.data.SaveTimestamp; - if (timestamp && timestamp !== "0") { - data.exportDate = new Date(parseInt(timestamp, 10)); - } - - setImportData(data); + async function onImport(event: React.ChangeEvent): Promise { + try { + const base64Save = await saveObject.getImportStringFromFile(event.target.files); + const data = await saveObject.getImportDataFromString(base64Save); + setImportData(data) setImportSaveOpen(true); - }; - reader.readAsText(file); + } catch (ex: any) { + SnackbarEvents.emit(ex.toString(), "error", 5000); + } } - function confirmedImportGame(): void { + async function confirmedImportGame(): Promise { if (!importData) return; + try { + await saveObject.importGame(importData.base64); + } catch (ex: any) { + SnackbarEvents.emit(ex.toString(), "error", 5000); + } + setImportSaveOpen(false); - save(importData.base64).then(() => { - setImportData(null); - setTimeout(() => location.reload(), 1000); - }); + setImportData(null); } return ( diff --git a/src/utils/StringHelperFunctions.ts b/src/utils/StringHelperFunctions.ts index 44a3cb276..58c685871 100644 --- a/src/utils/StringHelperFunctions.ts +++ b/src/utils/StringHelperFunctions.ts @@ -97,4 +97,31 @@ function generateRandomString(n: number): string { return str; } -export { convertTimeMsToTimeElapsedString, longestCommonStart, containsAllStrings, formatNumber, generateRandomString }; +/** + * Hashes the input string. This is a fast hash, so NOT good for cryptography. + * This has been ripped off here: https://stackoverflow.com/a/52171480 + * @param str The string that is to be hashed + * @param seed A seed to randomize the result + * @returns An hexadecimal string representation of the hashed input + */ +function cyrb53(str: string, seed = 0): string { + let h1 = 0xdeadbeef ^ seed; + let h2 = 0x41c6ce57 ^ seed; + for (let i = 0, ch; i < str.length; i++) { + ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16); +} + +export { + convertTimeMsToTimeElapsedString, + longestCommonStart, + containsAllStrings, + formatNumber, + generateRandomString, + cyrb53, +};