mirror of
https://github.com/bitburner-official/bitburner-src.git
synced 2024-11-26 09:33:49 +01:00
Merge pull request #2781 from MartinFournier/feat/steam-saves
Add Steam Cloud saves & filesystem saves to Electron
This commit is contained in:
commit
26d5e4dae0
@ -9,7 +9,7 @@ async function enableAchievementsInterval(window) {
|
||||
// This is backward but the game fills in an array called `document.achievements` and we retrieve it from
|
||||
// here. Hey if it works it works.
|
||||
const steamAchievements = greenworks.getAchievementNames();
|
||||
log.debug(`All Steam achievements ${JSON.stringify(steamAchievements)}`);
|
||||
log.silly(`All Steam achievements ${JSON.stringify(steamAchievements)}`);
|
||||
const playerAchieved = (await Promise.all(steamAchievements.map(checkSteamAchievement))).filter(name => !!name);
|
||||
log.debug(`Player has Steam achievements ${JSON.stringify(playerAchieved)}`);
|
||||
const intervalID = setInterval(async () => {
|
||||
|
@ -27,6 +27,7 @@ async function createWindow(killall) {
|
||||
backgroundColor: "#000000",
|
||||
webPreferences: {
|
||||
nativeWindowOpen: true,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
},
|
||||
});
|
||||
|
||||
|
114
electron/main.js
114
electron/main.js
@ -1,12 +1,19 @@
|
||||
/* eslint-disable no-process-exit */
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const { app, dialog, BrowserWindow } = require("electron");
|
||||
const { app, dialog, BrowserWindow, ipcMain } = require("electron");
|
||||
const log = require("electron-log");
|
||||
const greenworks = require("./greenworks");
|
||||
const api = require("./api-server");
|
||||
const gameWindow = require("./gameWindow");
|
||||
const achievements = require("./achievements");
|
||||
const utils = require("./utils");
|
||||
const storage = require("./storage");
|
||||
const debounce = require("lodash/debounce");
|
||||
const Config = require("electron-config");
|
||||
const config = new Config();
|
||||
|
||||
log.transports.file.level = config.get("file-log-level", "info");
|
||||
log.transports.console.level = config.get("console-log-level", "debug");
|
||||
|
||||
log.catchErrors();
|
||||
log.info(`Started app: ${JSON.stringify(process.argv)}`);
|
||||
@ -30,6 +37,8 @@ try {
|
||||
global.greenworksError = ex.message;
|
||||
}
|
||||
|
||||
let isRestoreDisabled = false;
|
||||
|
||||
function setStopProcessHandler(app, window, enabled) {
|
||||
const closingWindowHandler = async (e) => {
|
||||
// We need to prevent the default closing event to add custom logic
|
||||
@ -41,6 +50,18 @@ function setStopProcessHandler(app, window, enabled) {
|
||||
// Shutdown the http server
|
||||
api.disable();
|
||||
|
||||
// Trigger debounced saves right now before closing
|
||||
try {
|
||||
await saveToDisk.flush();
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
}
|
||||
try {
|
||||
await saveToCloud.flush();
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
}
|
||||
|
||||
// Because of a steam limitation, if the player has launched an external browser,
|
||||
// steam will keep displaying the game as "Running" in their UI as long as the browser is up.
|
||||
// So we'll alert the player to close their browser.
|
||||
@ -87,13 +108,98 @@ function setStopProcessHandler(app, window, enabled) {
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
const receivedGameReadyHandler = async (event, arg) => {
|
||||
if (!window) {
|
||||
log.warn("Window was undefined in game info handler");
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("Received game information", arg);
|
||||
window.gameInfo = { ...arg };
|
||||
await storage.prepareSaveFolders(window);
|
||||
|
||||
const restoreNewest = config.get("onload-restore-newest", true);
|
||||
if (restoreNewest && !isRestoreDisabled) {
|
||||
try {
|
||||
await storage.restoreIfNewerExists(window)
|
||||
} catch (error) {
|
||||
log.error("Could not restore newer file", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const receivedDisableRestoreHandler = async (event, arg) => {
|
||||
if (!window) {
|
||||
log.warn("Window was undefined in disable import handler");
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug(`Disabling auto-restore for ${arg.duration}ms.`);
|
||||
isRestoreDisabled = true;
|
||||
setTimeout(() => {
|
||||
isRestoreDisabled = false;
|
||||
log.debug("Re-enabling auto-restore");
|
||||
}, arg.duration);
|
||||
}
|
||||
|
||||
const receivedGameSavedHandler = async (event, arg) => {
|
||||
if (!window) {
|
||||
log.warn("Window was undefined in game saved handler");
|
||||
return;
|
||||
}
|
||||
|
||||
const { save, ...other } = arg;
|
||||
log.silly("Received game saved info", {...other, save: `${save.length} bytes`});
|
||||
|
||||
if (storage.isAutosaveEnabled()) {
|
||||
saveToDisk(save, arg.fileName);
|
||||
}
|
||||
if (storage.isCloudEnabled()) {
|
||||
const minimumPlaytime = 1000 * 60 * 15;
|
||||
const playtime = window.gameInfo.player.playtime;
|
||||
log.silly(window.gameInfo);
|
||||
if (playtime > minimumPlaytime) {
|
||||
saveToCloud(save);
|
||||
} else {
|
||||
log.debug(`Auto-save to cloud disabled for save game under ${minimumPlaytime}ms (${playtime}ms)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const saveToCloud = debounce(async (save) => {
|
||||
log.debug("Saving to Steam Cloud ...")
|
||||
try {
|
||||
const playerId = window.gameInfo.player.identifier;
|
||||
await storage.pushGameSaveToSteamCloud(save, playerId);
|
||||
log.silly("Saved Game to Steam Cloud");
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
utils.writeToast(window, "Could not save to Steam Cloud.", "error", 5000);
|
||||
}
|
||||
}, config.get("cloud-save-min-time", 1000 * 60 * 15), { leading: true });
|
||||
|
||||
const saveToDisk = debounce(async (save, fileName) => {
|
||||
log.debug("Saving to Disk ...")
|
||||
try {
|
||||
const file = await storage.saveGameToDisk(window, { save, fileName });
|
||||
log.silly(`Saved Game to '${file.replaceAll('\\', '\\\\')}'`);
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
utils.writeToast(window, "Could not save to disk", "error", 5000);
|
||||
}
|
||||
}, config.get("disk-save-min-time", 1000 * 60 * 5), { leading: true });
|
||||
|
||||
if (enabled) {
|
||||
log.debug('Adding closing handlers');
|
||||
log.debug("Adding closing handlers");
|
||||
ipcMain.on("push-game-ready", receivedGameReadyHandler);
|
||||
ipcMain.on("push-game-saved", receivedGameSavedHandler);
|
||||
ipcMain.on("push-disable-restore", receivedDisableRestoreHandler)
|
||||
window.on("closed", clearWindowHandler);
|
||||
window.on("close", closingWindowHandler)
|
||||
app.on("window-all-closed", stopProcessHandler);
|
||||
} else {
|
||||
log.debug('Removing closing handlers');
|
||||
log.debug("Removing closing handlers");
|
||||
ipcMain.removeAllListeners();
|
||||
window.removeListener("closed", clearWindowHandler);
|
||||
window.removeListener("close", closingWindowHandler);
|
||||
app.removeListener("window-all-closed", stopProcessHandler);
|
||||
@ -110,7 +216,7 @@ global.app_handlers = {
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
log.info('Application is ready!');
|
||||
log.info("Application is ready!");
|
||||
|
||||
if (process.argv.includes("--export-save")) {
|
||||
const window = new BrowserWindow({ show: false });
|
||||
|
171
electron/menu.js
171
electron/menu.js
@ -1,11 +1,169 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const { Menu, clipboard, dialog } = require("electron");
|
||||
const { app, Menu, clipboard, dialog, shell } = require("electron");
|
||||
const log = require("electron-log");
|
||||
const Config = require("electron-config");
|
||||
const api = require("./api-server");
|
||||
const utils = require("./utils");
|
||||
const storage = require("./storage");
|
||||
const config = new Config();
|
||||
|
||||
function getMenu(window) {
|
||||
return Menu.buildFromTemplate([
|
||||
{
|
||||
label: "File",
|
||||
submenu: [
|
||||
{
|
||||
label: "Save Game",
|
||||
click: () => window.webContents.send("trigger-save"),
|
||||
},
|
||||
{
|
||||
label: "Export Save",
|
||||
click: () => window.webContents.send("trigger-game-export"),
|
||||
},
|
||||
{
|
||||
label: "Export Scripts",
|
||||
click: async () => window.webContents.send("trigger-scripts-export"),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Load Last Save",
|
||||
click: async () => {
|
||||
try {
|
||||
const saveGame = await storage.loadLastFromDisk(window);
|
||||
window.webContents.send("push-save-request", { save: saveGame });
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
utils.writeToast(window, "Could not load last save from disk", "error", 5000);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Load From File",
|
||||
click: async () => {
|
||||
const defaultPath = await storage.getSaveFolder(window);
|
||||
const result = await dialog.showOpenDialog(window, {
|
||||
title: "Load From File",
|
||||
defaultPath: defaultPath,
|
||||
buttonLabel: "Load",
|
||||
filters: [
|
||||
{ name: "Game Saves", extensions: ["json", "json.gz", "txt"] },
|
||||
{ name: "All", extensions: ["*"] },
|
||||
],
|
||||
properties: [
|
||||
"openFile", "dontAddToRecent",
|
||||
]
|
||||
});
|
||||
if (result.canceled) return;
|
||||
const file = result.filePaths[0];
|
||||
|
||||
try {
|
||||
const saveGame = await storage.loadFileFromDisk(file);
|
||||
window.webContents.send("push-save-request", { save: saveGame });
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
utils.writeToast(window, "Could not load save from disk", "error", 5000);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Load From Steam Cloud",
|
||||
enabled: storage.isCloudEnabled(),
|
||||
click: async () => {
|
||||
try {
|
||||
const saveGame = await storage.getSteamCloudSaveString();
|
||||
await storage.pushSaveGameForImport(window, saveGame, false);
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
utils.writeToast(window, "Could not load from Steam Cloud", "error", 5000);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Compress Disk Saves (.gz)",
|
||||
type: "checkbox",
|
||||
checked: storage.isSaveCompressionEnabled(),
|
||||
click: (menuItem) => {
|
||||
storage.setSaveCompressionConfig(menuItem.checked);
|
||||
utils.writeToast(window,
|
||||
`${menuItem.checked ? "Enabled" : "Disabled"} Save Compression`, "info", 5000);
|
||||
refreshMenu(window);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Auto-Save to Disk",
|
||||
type: "checkbox",
|
||||
checked: storage.isAutosaveEnabled(),
|
||||
click: (menuItem) => {
|
||||
storage.setAutosaveConfig(menuItem.checked);
|
||||
utils.writeToast(window,
|
||||
`${menuItem.checked ? "Enabled" : "Disabled"} Auto-Save to Disk`, "info", 5000);
|
||||
refreshMenu(window);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Auto-Save to Steam Cloud",
|
||||
type: "checkbox",
|
||||
enabled: !global.greenworksError,
|
||||
checked: storage.isCloudEnabled(),
|
||||
click: (menuItem) => {
|
||||
storage.setCloudEnabledConfig(menuItem.checked);
|
||||
utils.writeToast(window,
|
||||
`${menuItem.checked ? "Enabled" : "Disabled"} Auto-Save to Steam Cloud`, "info", 5000);
|
||||
refreshMenu(window);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Restore Newest on Load",
|
||||
type: "checkbox",
|
||||
checked: config.get("onload-restore-newest", true),
|
||||
click: (menuItem) => {
|
||||
config.set("onload-restore-newest", menuItem.checked);
|
||||
utils.writeToast(window,
|
||||
`${menuItem.checked ? "Enabled" : "Disabled"} Restore Newest on Load`, "info", 5000);
|
||||
refreshMenu(window);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Open Directory",
|
||||
submenu: [
|
||||
{
|
||||
label: "Open Game Directory",
|
||||
click: () => shell.openPath(app.getAppPath()),
|
||||
},
|
||||
{
|
||||
label: "Open Saves Directory",
|
||||
click: async () => {
|
||||
const path = await storage.getSaveFolder(window);
|
||||
shell.openPath(path);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Open Logs Directory",
|
||||
click: () => shell.openPath(app.getPath("logs")),
|
||||
},
|
||||
{
|
||||
label: "Open Data Directory",
|
||||
click: () => shell.openPath(app.getPath("userData")),
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Quit",
|
||||
click: () => app.quit(),
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
submenu: [
|
||||
@ -163,6 +321,17 @@ function getMenu(window) {
|
||||
label: "Activate",
|
||||
click: () => window.webContents.openDevTools(),
|
||||
},
|
||||
{
|
||||
label: "Delete Steam Cloud Data",
|
||||
enabled: !global.greenworksError,
|
||||
click: async () => {
|
||||
try {
|
||||
await storage.deleteCloudFile();
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
13
electron/package-lock.json
generated
13
electron/package-lock.json
generated
@ -9,7 +9,8 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"electron-config": "^2.0.0",
|
||||
"electron-log": "^4.4.4"
|
||||
"electron-log": "^4.4.4",
|
||||
"lodash": "^4.17.21"
|
||||
}
|
||||
},
|
||||
"node_modules/conf": {
|
||||
@ -104,6 +105,11 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz",
|
||||
@ -259,6 +265,11 @@
|
||||
"path-exists": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"make-dir": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz",
|
||||
|
@ -25,6 +25,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-config": "^2.0.0",
|
||||
"electron-log": "^4.4.4"
|
||||
"electron-log": "^4.4.4",
|
||||
"lodash": "^4.17.21"
|
||||
}
|
||||
}
|
||||
|
38
electron/preload.js
Normal file
38
electron/preload.js
Normal file
@ -0,0 +1,38 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const { ipcRenderer, contextBridge } = require('electron')
|
||||
const log = require("electron-log");
|
||||
|
||||
contextBridge.exposeInMainWorld(
|
||||
"electronBridge", {
|
||||
send: (channel, data) => {
|
||||
log.log("Send on channel " + channel)
|
||||
// whitelist channels
|
||||
let validChannels = [
|
||||
"get-save-data-response",
|
||||
"get-save-info-response",
|
||||
"push-game-saved",
|
||||
"push-game-ready",
|
||||
"push-import-result",
|
||||
"push-disable-restore",
|
||||
];
|
||||
if (validChannels.includes(channel)) {
|
||||
ipcRenderer.send(channel, data);
|
||||
}
|
||||
},
|
||||
receive: (channel, func) => {
|
||||
log.log("Receive on channel " + channel)
|
||||
let validChannels = [
|
||||
"get-save-data-request",
|
||||
"get-save-info-request",
|
||||
"push-save-request",
|
||||
"trigger-save",
|
||||
"trigger-game-export",
|
||||
"trigger-scripts-export",
|
||||
];
|
||||
if (validChannels.includes(channel)) {
|
||||
// Deliberately strip event as it includes `sender`
|
||||
ipcRenderer.on(channel, (event, ...args) => func(...args));
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
386
electron/storage.js
Normal file
386
electron/storage.js
Normal file
@ -0,0 +1,386 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const { app, ipcMain } = require("electron");
|
||||
const zlib = require("zlib");
|
||||
const path = require("path");
|
||||
const fs = require("fs/promises");
|
||||
const { promisify } = require("util");
|
||||
const gzip = promisify(zlib.gzip);
|
||||
const gunzip = promisify(zlib.gunzip);
|
||||
|
||||
const greenworks = require("./greenworks");
|
||||
const log = require("electron-log");
|
||||
const flatten = require("lodash/flatten");
|
||||
const Config = require("electron-config");
|
||||
const config = new Config();
|
||||
|
||||
// https://stackoverflow.com/a/69418940
|
||||
const dirSize = async (directory) => {
|
||||
const files = await fs.readdir(directory);
|
||||
const stats = files.map(file => fs.stat(path.join(directory, file)));
|
||||
return (await Promise.all(stats)).reduce((accumulator, { size }) => accumulator + size, 0);
|
||||
}
|
||||
|
||||
const getDirFileStats = async (directory) => {
|
||||
const files = await fs.readdir(directory);
|
||||
const stats = files.map((f) => {
|
||||
const file = path.join(directory, f);
|
||||
return fs.stat(file).then((stat) => ({ file, stat }));
|
||||
});
|
||||
const data = (await Promise.all(stats));
|
||||
return data;
|
||||
};
|
||||
|
||||
const getNewestFile = async (directory) => {
|
||||
const data = await getDirFileStats(directory)
|
||||
return data.sort((a, b) => b.stat.mtime.getTime() - a.stat.mtime.getTime())[0];
|
||||
};
|
||||
|
||||
const getAllSaves = async (window) => {
|
||||
const rootDirectory = await getSaveFolder(window, true);
|
||||
const data = await fs.readdir(rootDirectory, { withFileTypes: true});
|
||||
const savesPromises = data.filter((e) => e.isDirectory()).
|
||||
map((dir) => path.join(rootDirectory, dir.name)).
|
||||
map((dir) => getDirFileStats(dir));
|
||||
const saves = await Promise.all(savesPromises);
|
||||
const flat = flatten(saves);
|
||||
return flat;
|
||||
}
|
||||
|
||||
async function prepareSaveFolders(window) {
|
||||
const rootFolder = await getSaveFolder(window, true);
|
||||
const currentFolder = await getSaveFolder(window);
|
||||
const backupsFolder = path.join(rootFolder, "/_backups")
|
||||
await prepareFolders(rootFolder, currentFolder, backupsFolder);
|
||||
}
|
||||
|
||||
async function prepareFolders(...folders) {
|
||||
for (const folder of folders) {
|
||||
try {
|
||||
// Making sure the folder exists
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await fs.stat(folder);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
log.warn(`'${folder}' not found, creating it...`);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await fs.mkdir(folder);
|
||||
} else {
|
||||
log.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getFolderSizeInBytes(saveFolder) {
|
||||
try {
|
||||
return await dirSize(saveFolder);
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function setAutosaveConfig(value) {
|
||||
config.set("autosave-enabled", value);
|
||||
}
|
||||
|
||||
function isAutosaveEnabled() {
|
||||
return config.get("autosave-enabled", true);
|
||||
}
|
||||
|
||||
function setSaveCompressionConfig(value) {
|
||||
config.set("save-compression-enabled", value);
|
||||
}
|
||||
|
||||
function isSaveCompressionEnabled() {
|
||||
return config.get("save-compression-enabled", true);
|
||||
}
|
||||
|
||||
function setCloudEnabledConfig(value) {
|
||||
config.set("cloud-enabled", value);
|
||||
}
|
||||
|
||||
async function getSaveFolder(window, root = false) {
|
||||
if (root) return path.join(app.getPath("userData"), "/saves");
|
||||
const identifier = window.gameInfo?.player?.identifier ?? "";
|
||||
return path.join(app.getPath("userData"), "/saves", `/${identifier}`);
|
||||
}
|
||||
|
||||
function isCloudEnabled() {
|
||||
// If the Steam API could not be initialized on game start, we'll abort this.
|
||||
if (global.greenworksError) return false;
|
||||
|
||||
// If the user disables it in Steam there's nothing we can do
|
||||
if (!greenworks.isCloudEnabledForUser()) return false;
|
||||
|
||||
// Let's check the config file to see if it's been overriden
|
||||
const enabledInConf = config.get("cloud-enabled", true);
|
||||
if (!enabledInConf) return false;
|
||||
|
||||
const isAppEnabled = greenworks.isCloudEnabled();
|
||||
if (!isAppEnabled) greenworks.enableCloud(true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function saveCloudFile(name, content) {
|
||||
return new Promise((resolve, reject) => {
|
||||
greenworks.saveTextToFile(name, content, resolve, reject);
|
||||
})
|
||||
}
|
||||
|
||||
function getFirstCloudFile() {
|
||||
const nbFiles = greenworks.getFileCount();
|
||||
if (nbFiles === 0) throw new Error('No files in cloud');
|
||||
const file = greenworks.getFileNameAndSize(0);
|
||||
log.silly(`Found ${nbFiles} files.`)
|
||||
log.silly(`First File: ${file.name} (${file.size} bytes)`);
|
||||
return file.name;
|
||||
}
|
||||
|
||||
function getCloudFile() {
|
||||
const file = getFirstCloudFile();
|
||||
return new Promise((resolve, reject) => {
|
||||
greenworks.readTextFromFile(file, resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
function deleteCloudFile() {
|
||||
const file = getFirstCloudFile();
|
||||
return new Promise((resolve, reject) => {
|
||||
greenworks.deleteFile(file, resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function getSteamCloudQuota() {
|
||||
return new Promise((resolve, reject) => {
|
||||
greenworks.getCloudQuota(resolve, reject)
|
||||
});
|
||||
}
|
||||
|
||||
async function backupSteamDataToDisk(currentPlayerId) {
|
||||
const nbFiles = greenworks.getFileCount();
|
||||
if (nbFiles === 0) return;
|
||||
|
||||
const file = greenworks.getFileNameAndSize(0);
|
||||
const previousPlayerId = file.name.replace(".json.gz", "");
|
||||
if (previousPlayerId !== currentPlayerId) {
|
||||
const backupSave = await getSteamCloudSaveString();
|
||||
const backupFile = path.join(app.getPath("userData"), "/saves/_backups", `${previousPlayerId}.json.gz`);
|
||||
const buffer = Buffer.from(backupSave, 'base64').toString('utf8');
|
||||
saveContent = await gzip(buffer);
|
||||
await fs.writeFile(backupFile, saveContent, 'utf8');
|
||||
log.debug(`Saved backup game to '${backupFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function pushGameSaveToSteamCloud(base64save, currentPlayerId) {
|
||||
if (!isCloudEnabled) return Promise.reject("Steam Cloud is not Enabled");
|
||||
|
||||
try {
|
||||
backupSteamDataToDisk(currentPlayerId);
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
}
|
||||
|
||||
const steamSaveName = `${currentPlayerId}.json.gz`;
|
||||
|
||||
// Let's decode the base64 string so GZIP is more efficient.
|
||||
const buffer = Buffer.from(base64save, "base64");
|
||||
const compressedBuffer = await gzip(buffer);
|
||||
// We can't use utf8 for some reason, steamworks is unhappy.
|
||||
const content = compressedBuffer.toString("base64");
|
||||
log.debug(`Uncompressed: ${base64save.length} bytes`);
|
||||
log.debug(`Compressed: ${content.length} bytes`);
|
||||
log.debug(`Saving to Steam Cloud as ${steamSaveName}`);
|
||||
|
||||
try {
|
||||
await saveCloudFile(steamSaveName, content);
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function getSteamCloudSaveString() {
|
||||
if (!isCloudEnabled()) return Promise.reject("Steam Cloud is not Enabled");
|
||||
log.debug(`Fetching Save in Steam Cloud`);
|
||||
const cloudString = await getCloudFile();
|
||||
const gzippedBase64Buffer = Buffer.from(cloudString, "base64");
|
||||
const uncompressedBuffer = await gunzip(gzippedBase64Buffer);
|
||||
const content = uncompressedBuffer.toString("base64");
|
||||
log.debug(`Compressed: ${cloudString.length} bytes`);
|
||||
log.debug(`Uncompressed: ${content.length} bytes`);
|
||||
return content;
|
||||
}
|
||||
|
||||
async function saveGameToDisk(window, saveData) {
|
||||
const currentFolder = await getSaveFolder(window);
|
||||
let saveFolderSizeBytes = await getFolderSizeInBytes(currentFolder);
|
||||
const maxFolderSizeBytes = config.get("autosave-quota", 1e8); // 100Mb per playerIndentifier
|
||||
const remainingSpaceBytes = maxFolderSizeBytes - saveFolderSizeBytes;
|
||||
log.debug(`Folder Usage: ${saveFolderSizeBytes} bytes`);
|
||||
log.debug(`Folder Capacity: ${maxFolderSizeBytes} bytes`);
|
||||
log.debug(`Remaining: ${remainingSpaceBytes} bytes (${(saveFolderSizeBytes / maxFolderSizeBytes * 100).toFixed(2)}% used)`)
|
||||
const shouldCompress = isSaveCompressionEnabled();
|
||||
const fileName = saveData.fileName;
|
||||
const file = path.join(currentFolder, fileName + (shouldCompress ? ".gz" : ""));
|
||||
try {
|
||||
let saveContent = saveData.save;
|
||||
if (shouldCompress) {
|
||||
// Let's decode the base64 string so GZIP is more efficient.
|
||||
const buffer = Buffer.from(saveContent, 'base64').toString('utf8');
|
||||
saveContent = await gzip(buffer);
|
||||
}
|
||||
await fs.writeFile(file, saveContent, 'utf8');
|
||||
log.debug(`Saved Game to '${file}'`);
|
||||
log.debug(`Save Size: ${saveContent.length} bytes`);
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
}
|
||||
|
||||
const fileStats = await getDirFileStats(currentFolder);
|
||||
const oldestFiles = fileStats
|
||||
.sort((a, b) => a.stat.mtime.getTime() - b.stat.mtime.getTime())
|
||||
.map(f => f.file).filter(f => f !== file);
|
||||
|
||||
while (saveFolderSizeBytes > maxFolderSizeBytes && oldestFiles.length > 0) {
|
||||
const fileToRemove = oldestFiles.shift();
|
||||
log.debug(`Over Quota -> Removing "${fileToRemove}"`);
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await fs.unlink(fileToRemove);
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
saveFolderSizeBytes = await getFolderSizeInBytes(currentFolder);
|
||||
log.debug(`Save Folder: ${saveFolderSizeBytes} bytes`);
|
||||
log.debug(`Remaining: ${maxFolderSizeBytes - saveFolderSizeBytes} bytes (${(saveFolderSizeBytes / maxFolderSizeBytes * 100).toFixed(2)}% used)`)
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
async function loadLastFromDisk(window) {
|
||||
const folder = await getSaveFolder(window);
|
||||
const last = await getNewestFile(folder);
|
||||
log.debug(`Last modified file: "${last.file}" (${last.stat.mtime.toLocaleString()})`);
|
||||
return loadFileFromDisk(last.file);
|
||||
}
|
||||
|
||||
async function loadFileFromDisk(path) {
|
||||
const buffer = await fs.readFile(path);
|
||||
let content;
|
||||
if (path.endsWith('.gz')) {
|
||||
const uncompressedBuffer = await gunzip(buffer);
|
||||
content = uncompressedBuffer.toString('base64');
|
||||
log.debug(`Uncompressed file content (new size: ${content.length} bytes)`);
|
||||
} else {
|
||||
content = buffer.toString('utf8');
|
||||
log.debug(`Loaded file with ${content.length} bytes`)
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function getSaveInformation(window, save) {
|
||||
return new Promise((resolve) => {
|
||||
ipcMain.once("get-save-info-response", async (event, data) => {
|
||||
resolve(data);
|
||||
});
|
||||
window.webContents.send("get-save-info-request", save);
|
||||
});
|
||||
}
|
||||
|
||||
function getCurrentSave(window) {
|
||||
return new Promise((resolve) => {
|
||||
ipcMain.once('get-save-data-response', (event, data) => {
|
||||
resolve(data);
|
||||
});
|
||||
window.webContents.send('get-save-data-request');
|
||||
});
|
||||
}
|
||||
|
||||
function pushSaveGameForImport(window, save, automatic) {
|
||||
ipcMain.once("push-import-result", async (event, arg) => {
|
||||
log.debug(`Was save imported? ${arg.wasImported ? "Yes" : "No"}`);
|
||||
});
|
||||
window.webContents.send("push-save-request", { save, automatic });
|
||||
}
|
||||
|
||||
async function restoreIfNewerExists(window) {
|
||||
const currentSave = await getCurrentSave(window);
|
||||
const currentData = await getSaveInformation(window, currentSave.save);
|
||||
const steam = {};
|
||||
const disk = {};
|
||||
|
||||
try {
|
||||
steam.save = await getSteamCloudSaveString();
|
||||
steam.data = await getSaveInformation(window, steam.save);
|
||||
} catch (error) {
|
||||
log.error("Could not retrieve steam file");
|
||||
log.debug(error);
|
||||
}
|
||||
|
||||
try {
|
||||
const saves = (await getAllSaves()).
|
||||
sort((a, b) => b.stat.mtime.getTime() - a.stat.mtime.getTime());
|
||||
if (saves.length > 0) {
|
||||
disk.save = await loadFileFromDisk(saves[0].file);
|
||||
disk.data = await getSaveInformation(window, disk.save);
|
||||
}
|
||||
} catch(error) {
|
||||
log.error("Could not retrieve disk file");
|
||||
log.debug(error);
|
||||
}
|
||||
|
||||
const lowPlaytime = 1000 * 60 * 15;
|
||||
let bestMatch;
|
||||
if (!steam.data && !disk.data) {
|
||||
log.info("No data to import");
|
||||
} else {
|
||||
// We'll just compare using the lastSave field for now.
|
||||
if (!steam.data) {
|
||||
log.debug('Best potential save match: Disk');
|
||||
bestMatch = disk;
|
||||
} else if (!disk.data) {
|
||||
log.debug('Best potential save match: Steam Cloud');
|
||||
bestMatch = steam;
|
||||
} else if ((steam.data.lastSave >= disk.data.lastSave)
|
||||
|| (steam.data.playtime + lowPlaytime > disk.data.playtime)) {
|
||||
// We want to prioritze steam data if the playtime is very close
|
||||
log.debug('Best potential save match: Steam Cloud');
|
||||
bestMatch = steam;
|
||||
} else {
|
||||
log.debug('Best potential save match: disk');
|
||||
bestMatch = disk;
|
||||
}
|
||||
}
|
||||
if (bestMatch) {
|
||||
if (bestMatch.data.lastSave > currentData.lastSave + 5000) {
|
||||
// We add a few seconds to the currentSave's lastSave to prioritize it
|
||||
log.info("Found newer data than the current's save file");
|
||||
log.silly(bestMatch.data);
|
||||
await pushSaveGameForImport(window, bestMatch.save, true);
|
||||
return true;
|
||||
} else if(bestMatch.data.playtime > currentData.playtime && currentData.playtime < lowPlaytime) {
|
||||
log.info("Found older save, but with more playtime, and current less than 15 mins played");
|
||||
log.silly(bestMatch.data);
|
||||
await pushSaveGameForImport(window, bestMatch.save, true);
|
||||
return true;
|
||||
} else {
|
||||
log.debug("Current save data is the freshest");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCurrentSave, getSaveInformation,
|
||||
restoreIfNewerExists, pushSaveGameForImport,
|
||||
pushGameSaveToSteamCloud, getSteamCloudSaveString, getSteamCloudQuota, deleteCloudFile,
|
||||
saveGameToDisk, loadLastFromDisk, loadFileFromDisk,
|
||||
getSaveFolder, prepareSaveFolders, getAllSaves,
|
||||
isCloudEnabled, setCloudEnabledConfig,
|
||||
isAutosaveEnabled, setAutosaveConfig,
|
||||
isSaveCompressionEnabled, setSaveCompressionConfig,
|
||||
};
|
131
src/Electron.tsx
131
src/Electron.tsx
@ -1,10 +1,18 @@
|
||||
import { Player } from "./Player";
|
||||
import { Router } from "./ui/GameRoot";
|
||||
import { isScriptFilename } from "./Script/isScriptFilename";
|
||||
import { Script } from "./Script/Script";
|
||||
import { removeLeadingSlash } from "./Terminal/DirectoryHelpers";
|
||||
import { Terminal } from "./Terminal";
|
||||
import { SnackbarEvents } from "./ui/React/Snackbar";
|
||||
import { IMap, IReturnStatus } from "./types";
|
||||
import { GetServer } from "./Server/AllServers";
|
||||
import { resolve } from "cypress/types/bluebird";
|
||||
import { ImportPlayerData, SaveData, saveObject } from "./SaveObject";
|
||||
import { Settings } from "./Settings/Settings";
|
||||
import { exportScripts } from "./Terminal/commands/download";
|
||||
import { CONSTANTS } from "./Constants";
|
||||
import { hash } from "./hash/hash";
|
||||
|
||||
export function initElectron(): void {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
@ -13,6 +21,8 @@ export function initElectron(): void {
|
||||
(document as any).achievements = [];
|
||||
initWebserver();
|
||||
initAppNotifier();
|
||||
initSaveFunctions();
|
||||
initElectronBridge();
|
||||
}
|
||||
}
|
||||
|
||||
@ -109,6 +119,123 @@ function initAppNotifier(): void {
|
||||
};
|
||||
|
||||
// Will be consumud by the electron wrapper.
|
||||
// @ts-ignore
|
||||
window.appNotifier = funcs;
|
||||
(window as any).appNotifier = funcs;
|
||||
}
|
||||
|
||||
function initSaveFunctions(): void {
|
||||
const funcs = {
|
||||
triggerSave: (): Promise<void> => saveObject.saveGame(true),
|
||||
triggerGameExport: (): void => {
|
||||
try {
|
||||
saveObject.exportGame();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
SnackbarEvents.emit("Could not export game.", "error", 2000);
|
||||
}
|
||||
},
|
||||
triggerScriptsExport: (): void => exportScripts("*", Player.getHomeComputer()),
|
||||
getSaveData: (): { save: string; fileName: string } => {
|
||||
return {
|
||||
save: saveObject.getSaveString(Settings.ExcludeRunningScriptsFromSave),
|
||||
fileName: saveObject.getSaveFileName(),
|
||||
};
|
||||
},
|
||||
getSaveInfo: async (base64save: string): Promise<ImportPlayerData | undefined> => {
|
||||
try {
|
||||
const data = await saveObject.getImportDataFromString(base64save);
|
||||
return data.playerData;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
},
|
||||
pushSaveData: (base64save: string, automatic = false): void => Router.toImportSave(base64save, automatic),
|
||||
};
|
||||
|
||||
// Will be consumud by the electron wrapper.
|
||||
(window as any).appSaveFns = funcs;
|
||||
}
|
||||
|
||||
function initElectronBridge(): void {
|
||||
const bridge = (window as any).electronBridge as any;
|
||||
if (!bridge) return;
|
||||
|
||||
bridge.receive("get-save-data-request", () => {
|
||||
const data = (window as any).appSaveFns.getSaveData();
|
||||
bridge.send("get-save-data-response", data);
|
||||
});
|
||||
bridge.receive("get-save-info-request", async (save: string) => {
|
||||
const data = await (window as any).appSaveFns.getSaveInfo(save);
|
||||
bridge.send("get-save-info-response", data);
|
||||
});
|
||||
bridge.receive("push-save-request", ({ save, automatic = false }: { save: string; automatic: boolean }) => {
|
||||
(window as any).appSaveFns.pushSaveData(save, automatic);
|
||||
});
|
||||
bridge.receive("trigger-save", () => {
|
||||
return (window as any).appSaveFns
|
||||
.triggerSave()
|
||||
.then(() => {
|
||||
bridge.send("save-completed");
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.log(error);
|
||||
SnackbarEvents.emit("Could not save game.", "error", 2000);
|
||||
});
|
||||
});
|
||||
bridge.receive("trigger-game-export", () => {
|
||||
try {
|
||||
(window as any).appSaveFns.triggerGameExport();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
SnackbarEvents.emit("Could not export game.", "error", 2000);
|
||||
}
|
||||
});
|
||||
bridge.receive("trigger-scripts-export", () => {
|
||||
try {
|
||||
(window as any).appSaveFns.triggerScriptsExport();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
SnackbarEvents.emit("Could not export scripts.", "error", 2000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function pushGameSaved(data: SaveData): void {
|
||||
const bridge = (window as any).electronBridge as any;
|
||||
if (!bridge) return;
|
||||
|
||||
bridge.send("push-game-saved", data);
|
||||
}
|
||||
|
||||
export function pushGameReady(): void {
|
||||
const bridge = (window as any).electronBridge as any;
|
||||
if (!bridge) return;
|
||||
|
||||
// Send basic information to the electron wrapper
|
||||
bridge.send("push-game-ready", {
|
||||
player: {
|
||||
identifier: Player.identifier,
|
||||
playtime: Player.totalPlaytime,
|
||||
lastSave: Player.lastSave,
|
||||
},
|
||||
game: {
|
||||
version: CONSTANTS.VersionString,
|
||||
hash: hash(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function pushImportResult(wasImported: boolean): void {
|
||||
const bridge = (window as any).electronBridge as any;
|
||||
if (!bridge) return;
|
||||
|
||||
bridge.send("push-import-result", { wasImported });
|
||||
pushDisableRestore();
|
||||
}
|
||||
|
||||
export function pushDisableRestore(): void {
|
||||
const bridge = (window as any).electronBridge as any;
|
||||
if (!bridge) return;
|
||||
|
||||
bridge.send("push-disable-restore", { duration: 1000 * 60 });
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -23,11 +23,43 @@ 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";
|
||||
import { pushGameSaved } from "./Electron";
|
||||
|
||||
/* SaveObject.js
|
||||
* Defines the object used to save/load games
|
||||
*/
|
||||
|
||||
export interface SaveData {
|
||||
playerIdentifier: string;
|
||||
fileName: string;
|
||||
save: string;
|
||||
savedOn: number;
|
||||
}
|
||||
|
||||
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 +74,6 @@ class BitburnerSaveObject {
|
||||
AllGangsSave = "";
|
||||
LastExportBonus = "";
|
||||
StaneksGiftSave = "";
|
||||
SaveTimestamp = "";
|
||||
|
||||
getSaveString(excludeRunningScripts = false): string {
|
||||
this.PlayerSave = JSON.stringify(Player);
|
||||
@ -58,7 +89,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 +98,134 @@ class BitburnerSaveObject {
|
||||
return saveString;
|
||||
}
|
||||
|
||||
saveGame(emitToastEvent = true): void {
|
||||
saveGame(emitToastEvent = true): Promise<void> {
|
||||
const savedOn = new Date().getTime();
|
||||
Player.lastSave = savedOn;
|
||||
const saveString = this.getSaveString(Settings.ExcludeRunningScriptsFromSave);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
save(saveString)
|
||||
.then(() => {
|
||||
const saveData: SaveData = {
|
||||
playerIdentifier: Player.identifier,
|
||||
fileName: this.getSaveFileName(),
|
||||
save: saveString,
|
||||
savedOn,
|
||||
};
|
||||
pushGameSaved(saveData);
|
||||
|
||||
if (emitToastEvent) {
|
||||
SnackbarEvents.emit("Game Saved!", "info", 2000);
|
||||
}
|
||||
return resolve();
|
||||
})
|
||||
.catch((err) => console.error(err));
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
return reject();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<string> = new Promise((resolve, reject) => {
|
||||
reader.onload = function (this: FileReader, e: ProgressEvent<FileReader>) {
|
||||
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<ImportData> {
|
||||
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<number>((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<number>((total, current) => (total += current.lvl), 0) ?? 0,
|
||||
};
|
||||
|
||||
data.playerData = playerData;
|
||||
return Promise.resolve(data);
|
||||
}
|
||||
|
||||
toJSON(): any {
|
||||
return Generic_toJSON("BitburnerSaveObject", this);
|
||||
}
|
||||
|
@ -6,22 +6,8 @@ import { isScriptFilename } from "../../Script/isScriptFilename";
|
||||
import FileSaver from "file-saver";
|
||||
import JSZip from "jszip";
|
||||
|
||||
export function download(
|
||||
terminal: ITerminal,
|
||||
router: IRouter,
|
||||
player: IPlayer,
|
||||
server: BaseServer,
|
||||
args: (string | number | boolean)[],
|
||||
): void {
|
||||
try {
|
||||
if (args.length !== 1) {
|
||||
terminal.error("Incorrect usage of download command. Usage: download [script/text file]");
|
||||
return;
|
||||
}
|
||||
const fn = args[0] + "";
|
||||
// If the parameter starts with *, download all files that match the wildcard pattern
|
||||
if (fn.startsWith("*")) {
|
||||
const matchEnding = fn.length == 1 || fn === "*.*" ? null : fn.slice(1); // Treat *.* the same as *
|
||||
export function exportScripts(pattern: string, server: BaseServer): void {
|
||||
const matchEnding = pattern.length == 1 || pattern === "*.*" ? null : pattern.slice(1); // Treat *.* the same as *
|
||||
const zip = new JSZip();
|
||||
// Helper function to zip any file contents whose name matches the pattern
|
||||
const zipFiles = (fileNames: string[], fileContents: string[]): void => {
|
||||
@ -44,11 +30,34 @@ export function download(
|
||||
server.textFiles.map((s) => s.fn),
|
||||
server.textFiles.map((s) => s.text),
|
||||
);
|
||||
|
||||
// Return an error if no files matched, rather than an empty zip folder
|
||||
if (Object.keys(zip.files).length == 0) return terminal.error(`No files match the pattern ${fn}`);
|
||||
const zipFn = `bitburner${isScriptFilename(fn) ? "Scripts" : fn === "*.txt" ? "Texts" : "Files"}.zip`;
|
||||
if (Object.keys(zip.files).length == 0) throw new Error(`No files match the pattern ${pattern}`);
|
||||
const zipFn = `bitburner${isScriptFilename(pattern) ? "Scripts" : pattern === "*.txt" ? "Texts" : "Files"}.zip`;
|
||||
zip.generateAsync({ type: "blob" }).then((content: any) => FileSaver.saveAs(content, zipFn));
|
||||
}
|
||||
|
||||
export function download(
|
||||
terminal: ITerminal,
|
||||
router: IRouter,
|
||||
player: IPlayer,
|
||||
server: BaseServer,
|
||||
args: (string | number | boolean)[],
|
||||
): void {
|
||||
try {
|
||||
if (args.length !== 1) {
|
||||
terminal.error("Incorrect usage of download command. Usage: download [script/text file]");
|
||||
return;
|
||||
}
|
||||
const fn = args[0] + "";
|
||||
// If the parameter starts with *, download all files that match the wildcard pattern
|
||||
if (fn.startsWith("*")) {
|
||||
try {
|
||||
exportScripts(fn, server);
|
||||
return;
|
||||
} catch (error: any) {
|
||||
return terminal.error(error.message);
|
||||
}
|
||||
} else if (isScriptFilename(fn)) {
|
||||
// Download a single script
|
||||
const script = terminal.getScript(player, fn);
|
||||
|
@ -81,6 +81,11 @@ 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";
|
||||
|
||||
import _wrap from "lodash/wrap";
|
||||
import _functions from "lodash/functions";
|
||||
|
||||
const htmlLocation = location;
|
||||
|
||||
@ -109,6 +114,9 @@ export let Router: IRouter = {
|
||||
page: () => {
|
||||
throw new Error("Router called before initialization");
|
||||
},
|
||||
allowRouting: () => {
|
||||
throw new Error("Router called before initialization");
|
||||
},
|
||||
toActiveScripts: () => {
|
||||
throw new Error("Router called before initialization");
|
||||
},
|
||||
@ -199,6 +207,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 +239,13 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme
|
||||
const [errorBoundaryKey, setErrorBoundaryKey] = useState<number>(0);
|
||||
const [sidebarOpened, setSideBarOpened] = useState(Settings.IsSidebarOpened);
|
||||
|
||||
const [importString, setImportString] = useState<string>(undefined as unknown as string);
|
||||
const [importAutomatic, setImportAutomatic] = useState<boolean>(false);
|
||||
if (importString === undefined && page === Page.ImportSave)
|
||||
throw new Error("Trying to go to a page without the proper setup");
|
||||
|
||||
const [allowRoutingCalls, setAllowRoutingCalls] = useState(true);
|
||||
|
||||
function resetErrorBoundary(): void {
|
||||
setErrorBoundaryKey(errorBoundaryKey + 1);
|
||||
}
|
||||
@ -249,6 +267,7 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme
|
||||
|
||||
Router = {
|
||||
page: () => page,
|
||||
allowRouting: (value: boolean) => setAllowRoutingCalls(value),
|
||||
toActiveScripts: () => setPage(Page.ActiveScripts),
|
||||
toAugmentations: () => setPage(Page.Augmentations),
|
||||
toBladeburner: () => setPage(Page.Bladeburner),
|
||||
@ -315,9 +334,34 @@ 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(() => {
|
||||
// Wrap Router navigate functions to be able to disable the execution
|
||||
_functions(Router).
|
||||
filter((fnName) => fnName.startsWith('to')).
|
||||
forEach((fnName) => {
|
||||
// @ts-ignore - tslint does not like this, couldn't find a way to make it cooperate
|
||||
Router[fnName] = _wrap(Router[fnName], (func, ...args) => {
|
||||
if (!allowRoutingCalls) {
|
||||
// Let's just log to console.
|
||||
console.log(`Routing is currently disabled - Attempted router.${fnName}()`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the function normally
|
||||
return func(...args);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (page !== Page.Terminal) window.scrollTo(0, 0);
|
||||
});
|
||||
@ -332,11 +376,13 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme
|
||||
let mainPage = <Typography>Cannot load</Typography>;
|
||||
let withSidebar = true;
|
||||
let withPopups = true;
|
||||
let bypassGame = false;
|
||||
switch (page) {
|
||||
case Page.Recovery: {
|
||||
mainPage = <RecoveryRoot router={Router} softReset={softReset} />;
|
||||
withSidebar = false;
|
||||
withPopups = false;
|
||||
bypassGame = true;
|
||||
break;
|
||||
}
|
||||
case Page.BitVerse: {
|
||||
@ -517,12 +563,25 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme
|
||||
mainPage = <ThemeBrowser router={Router} />;
|
||||
break;
|
||||
}
|
||||
case Page.ImportSave: {
|
||||
mainPage = (
|
||||
<ImportSaveRoot
|
||||
importString={importString}
|
||||
automatic={importAutomatic}
|
||||
router={Router}
|
||||
/>
|
||||
);
|
||||
withSidebar = false;
|
||||
withPopups = false;
|
||||
bypassGame = true;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Context.Player.Provider value={player}>
|
||||
<Context.Router.Provider value={Router}>
|
||||
<ErrorBoundary key={errorBoundaryKey} router={Router} softReset={softReset}>
|
||||
<BypassWrapper content={bypassGame ? mainPage : null}>
|
||||
<SnackbarProvider>
|
||||
<Overview mode={ITutorial.isRunning ? "tutorial" : "overview"}>
|
||||
{!ITutorial.isRunning ? (
|
||||
@ -533,12 +592,16 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme
|
||||
</Overview>
|
||||
{withSidebar ? (
|
||||
<Box display="flex" flexDirection="row" width="100%">
|
||||
<SidebarRoot player={player} router={Router} page={page}
|
||||
<SidebarRoot
|
||||
player={player}
|
||||
router={Router}
|
||||
page={page}
|
||||
opened={sidebarOpened}
|
||||
onToggled={(isOpened) => {
|
||||
setSideBarOpened(isOpened);
|
||||
Settings.IsSidebarOpened = isOpened;
|
||||
}} />
|
||||
}}
|
||||
/>
|
||||
<Box className={classes.root}>{mainPage}</Box>
|
||||
</Box>
|
||||
) : (
|
||||
@ -555,6 +618,7 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme
|
||||
</>
|
||||
)}
|
||||
</SnackbarProvider>
|
||||
</BypassWrapper>
|
||||
</ErrorBoundary>
|
||||
</Context.Router.Provider>
|
||||
</Context.Player.Provider>
|
||||
|
@ -16,6 +16,7 @@ import { GameRoot } from "./GameRoot";
|
||||
import { CONSTANTS } from "../Constants";
|
||||
import { ActivateRecoveryMode } from "./React/RecoveryRoot";
|
||||
import { hash } from "../hash/hash";
|
||||
import { pushGameReady } from "../Electron";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
@ -56,6 +57,7 @@ export function LoadingScreen(): React.ReactElement {
|
||||
throw err;
|
||||
}
|
||||
|
||||
pushGameReady();
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch((reason) => {
|
||||
|
11
src/ui/React/BypassWrapper.tsx
Normal file
11
src/ui/React/BypassWrapper.tsx
Normal file
@ -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}</>;
|
||||
}
|
@ -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
|
||||
</Button>
|
||||
{props.additionalButton && <>{props.additionalButton}</>}
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
|
@ -5,6 +5,7 @@ import Button from "@mui/material/Button";
|
||||
import { Tooltip } from '@mui/material';
|
||||
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { pushDisableRestore } from '../../Electron';
|
||||
|
||||
interface IProps {
|
||||
color?: "primary" | "warning" | "error";
|
||||
@ -21,7 +22,10 @@ export function DeleteGameButton({ color = "primary" }: IProps): React.ReactElem
|
||||
onConfirm={() => {
|
||||
setModalOpened(false);
|
||||
deleteGame()
|
||||
.then(() => setTimeout(() => location.reload(), 1000))
|
||||
.then(() => {
|
||||
pushDisableRestore();
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
})
|
||||
.catch((r) => console.error(`Could not delete game: ${r}`));
|
||||
}}
|
||||
open={modalOpened}
|
||||
|
@ -25,20 +25,20 @@ 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 { ImportData, saveObject } from "../../SaveObject";
|
||||
import { convertTimeMsToTimeElapsedString } from "../../utils/StringHelperFunctions";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
@ -59,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<HTMLInputElement>(null);
|
||||
@ -122,78 +116,35 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
|
||||
ii.click();
|
||||
}
|
||||
|
||||
function onImport(event: React.ChangeEvent<HTMLInputElement>): 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<FileReader>) {
|
||||
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;
|
||||
async function onImport(event: React.ChangeEvent<HTMLInputElement>): Promise<void> {
|
||||
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));
|
||||
}
|
||||
|
||||
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<void> {
|
||||
if (!importData) return;
|
||||
|
||||
try {
|
||||
await saveObject.importGame(importData.base64);
|
||||
} catch (ex: any) {
|
||||
SnackbarEvents.emit(ex.toString(), "error", 5000);
|
||||
}
|
||||
|
||||
setImportSaveOpen(false);
|
||||
setImportData(null);
|
||||
}
|
||||
|
||||
function compareSaveGame(): void {
|
||||
if (!importData) return;
|
||||
props.router.toImportSave(importData.base64);
|
||||
setImportSaveOpen(false);
|
||||
save(importData.base64).then(() => {
|
||||
setImportData(null);
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
@ -585,6 +536,7 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
|
||||
open={importSaveOpen}
|
||||
onClose={() => setImportSaveOpen(false)}
|
||||
onConfirm={() => confirmedImportGame()}
|
||||
additionalButton={<Button onClick={compareSaveGame}>Compare Save</Button>}
|
||||
confirmationText={
|
||||
<>
|
||||
Importing a new game will <strong>completely wipe</strong> the current data!
|
||||
@ -593,15 +545,24 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
|
||||
Make sure to have a backup of your current save file before importing.
|
||||
<br />
|
||||
The file you are attempting to import seems valid.
|
||||
<br />
|
||||
<br />
|
||||
{importData?.exportDate && (
|
||||
{(importData?.playerData?.lastSave ?? 0) > 0 && (
|
||||
<>
|
||||
The export date of the save file is <strong>{importData?.exportDate.toString()}</strong>
|
||||
<br />
|
||||
<br />
|
||||
The export date of the save file is{" "}
|
||||
<strong>{new Date(importData?.playerData?.lastSave ?? 0).toLocaleString()}</strong>
|
||||
</>
|
||||
)}
|
||||
{(importData?.playerData?.totalPlaytime ?? 0) > 0 && (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
Total play time of imported game:{" "}
|
||||
{convertTimeMsToTimeElapsedString(importData?.playerData?.totalPlaytime ?? 0)}
|
||||
</>
|
||||
)}
|
||||
<br />
|
||||
<br />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
351
src/ui/React/ImportSaveRoot.tsx
Normal file
351
src/ui/React/ImportSaveRoot.tsx
Normal file
@ -0,0 +1,351 @@
|
||||
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";
|
||||
import { pushImportResult } from "../../Electron";
|
||||
import { IRouter } from "../Router";
|
||||
|
||||
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 (
|
||||
<Tooltip
|
||||
title={
|
||||
<>
|
||||
Imported value is <b>larger</b>!
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ThumbUpAlt color="success" />
|
||||
</Tooltip>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
<>
|
||||
Imported value is <b>smaller</b>!
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ThumbDownAlt color="error" />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface IProps {
|
||||
importString: string;
|
||||
automatic: boolean;
|
||||
router: IRouter;
|
||||
}
|
||||
|
||||
let initialAutosave = 0;
|
||||
|
||||
export function ImportSaveRoot(props: IProps): JSX.Element {
|
||||
const classes = useStyles();
|
||||
const [importData, setImportData] = useState<ImportData | undefined>();
|
||||
const [currentData, setCurrentData] = useState<ImportData | undefined>();
|
||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||
const [headback, setHeadback] = useState(false);
|
||||
|
||||
function handleGoBack(): void {
|
||||
Settings.AutosaveInterval = initialAutosave;
|
||||
pushImportResult(false);
|
||||
props.router.allowRouting(true);
|
||||
setHeadback(true)
|
||||
}
|
||||
|
||||
async function handleImport(): Promise<void> {
|
||||
await saveObject.importGame(props.importString, true);
|
||||
pushImportResult(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// We want to disable autosave while we're in this mode
|
||||
initialAutosave = Settings.AutosaveInterval;
|
||||
Settings.AutosaveInterval = 0;
|
||||
props.router.allowRouting(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (headback) props.router.toTerminal();
|
||||
}, [headback]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData(): Promise<void> {
|
||||
const dataBeingImported = await saveObject.getImportDataFromString(props.importString);
|
||||
const dataCurrentlyInGame = await saveObject.getImportDataFromString(saveObject.getSaveString(true));
|
||||
|
||||
setImportData(dataBeingImported);
|
||||
setCurrentData(dataCurrentlyInGame);
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (props.importString) fetchData();
|
||||
}, [props.importString]);
|
||||
|
||||
if (!importData || !currentData) return <></>;
|
||||
return (
|
||||
<Box className={classes.root}>
|
||||
<Typography variant="h4" sx={{ mb: 2 }}>
|
||||
Import Save Comparison
|
||||
</Typography>
|
||||
{props.automatic && (
|
||||
<Typography sx={{ mb: 2 }}>
|
||||
We've found a <b>NEWER save</b> that you may want to use instead.
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
Your current game's data is on the left and the data that will be imported is on the right.
|
||||
<br />
|
||||
Please double check everything is fine before proceeding!
|
||||
</Typography>
|
||||
<TableContainer color="secondary" component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell></TableCell>
|
||||
<TableCell>Current Game</TableCell>
|
||||
<TableCell>Being Imported</TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Game Identifier</TableCell>
|
||||
<TableCell>{currentData.playerData?.identifier ?? "n/a"}</TableCell>
|
||||
<TableCell>{importData.playerData?.identifier ?? "n/a"}</TableCell>
|
||||
<TableCell>
|
||||
{importData.playerData?.identifier !== currentData.playerData?.identifier && (
|
||||
<Tooltip title="These are two different games!">
|
||||
<WarningIcon color="warning" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Playtime</TableCell>
|
||||
<TableCell>{convertTimeMsToTimeElapsedString(currentData.playerData?.totalPlaytime ?? 0)}</TableCell>
|
||||
<TableCell>{convertTimeMsToTimeElapsedString(importData.playerData?.totalPlaytime ?? 0)}</TableCell>
|
||||
<TableCell>
|
||||
{importData.playerData?.totalPlaytime !== currentData.playerData?.totalPlaytime && (
|
||||
<ComparisonIcon
|
||||
isBetter={
|
||||
(importData.playerData?.totalPlaytime ?? 0) > (currentData.playerData?.totalPlaytime ?? 0)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell>Saved On</TableCell>
|
||||
<TableCell>
|
||||
{(currentData.playerData?.lastSave ?? 0) > 0 ?
|
||||
new Date(currentData.playerData?.lastSave ?? 0).toLocaleString() : 'n/a'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(importData.playerData?.lastSave ?? 0) > 0 ?
|
||||
new Date(importData.playerData?.lastSave ?? 0).toLocaleString() : 'n/a'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{importData.playerData?.lastSave !== currentData.playerData?.lastSave && (
|
||||
<ComparisonIcon
|
||||
isBetter={(importData.playerData?.lastSave ?? 0) > (currentData.playerData?.lastSave ?? 0)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell>Money</TableCell>
|
||||
<TableCell>{numeralWrapper.formatMoney(currentData.playerData?.money ?? 0)}</TableCell>
|
||||
<TableCell>{numeralWrapper.formatMoney(importData.playerData?.money ?? 0)}</TableCell>
|
||||
<TableCell>
|
||||
{importData.playerData?.money !== currentData.playerData?.money && (
|
||||
<ComparisonIcon
|
||||
isBetter={(importData.playerData?.money ?? 0) > (currentData.playerData?.money ?? 0)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell>Hacking</TableCell>
|
||||
<TableCell>{numeralWrapper.formatSkill(currentData.playerData?.hacking ?? 0)}</TableCell>
|
||||
<TableCell>{numeralWrapper.formatSkill(importData.playerData?.hacking ?? 0)}</TableCell>
|
||||
<TableCell>
|
||||
{importData.playerData?.hacking !== currentData.playerData?.hacking && (
|
||||
<ComparisonIcon
|
||||
isBetter={(importData.playerData?.hacking ?? 0) > (currentData.playerData?.hacking ?? 0)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell>Augmentations</TableCell>
|
||||
<TableCell>{currentData.playerData?.augmentations}</TableCell>
|
||||
<TableCell>{importData.playerData?.augmentations}</TableCell>
|
||||
<TableCell>
|
||||
{importData.playerData?.augmentations !== currentData.playerData?.augmentations && (
|
||||
<ComparisonIcon
|
||||
isBetter={
|
||||
(importData.playerData?.augmentations ?? 0) > (currentData.playerData?.augmentations ?? 0)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell>Factions</TableCell>
|
||||
<TableCell>{currentData.playerData?.factions}</TableCell>
|
||||
<TableCell>{importData.playerData?.factions}</TableCell>
|
||||
<TableCell>
|
||||
{importData.playerData?.factions !== currentData.playerData?.factions && (
|
||||
<ComparisonIcon
|
||||
isBetter={(importData.playerData?.factions ?? 0) > (currentData.playerData?.factions ?? 0)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Achievements</TableCell>
|
||||
<TableCell>{currentData.playerData?.achievements}</TableCell>
|
||||
<TableCell>{importData.playerData?.achievements}</TableCell>
|
||||
<TableCell>
|
||||
{importData.playerData?.achievements !== currentData.playerData?.achievements && (
|
||||
<ComparisonIcon
|
||||
isBetter={(importData.playerData?.achievements ?? 0) > (currentData.playerData?.achievements ?? 0)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell>Source Files</TableCell>
|
||||
<TableCell>{currentData.playerData?.sourceFiles}</TableCell>
|
||||
<TableCell>{importData.playerData?.sourceFiles}</TableCell>
|
||||
<TableCell>
|
||||
{importData.playerData?.sourceFiles !== currentData.playerData?.sourceFiles && (
|
||||
<ComparisonIcon
|
||||
isBetter={(importData.playerData?.sourceFiles ?? 0) > (currentData.playerData?.sourceFiles ?? 0)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell>BitNode</TableCell>
|
||||
<TableCell>
|
||||
{currentData.playerData?.bitNode}-{currentData.playerData?.bitNodeLevel}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{importData.playerData?.bitNode}-{importData.playerData?.bitNodeLevel}
|
||||
</TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<ButtonGroup>
|
||||
<Button onClick={handleGoBack} sx={{ my: 2 }} startIcon={<ArrowBackIcon />} color="secondary">
|
||||
Take me back!
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setImportModalOpen(true)}
|
||||
sx={{ my: 2 }}
|
||||
startIcon={<DirectionsRunIcon />}
|
||||
color="warning"
|
||||
>
|
||||
Proceed with import
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<ConfirmationModal
|
||||
open={importModalOpen}
|
||||
onClose={() => setImportModalOpen(false)}
|
||||
onConfirm={handleImport}
|
||||
confirmationText={
|
||||
<>
|
||||
Importing new save game data will <strong>completely wipe</strong> the current game data!
|
||||
<br />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -38,6 +38,7 @@ export enum Page {
|
||||
Recovery,
|
||||
Achievements,
|
||||
ThemeBrowser,
|
||||
ImportSave,
|
||||
}
|
||||
|
||||
export interface ScriptEditorRouteOptions {
|
||||
@ -54,6 +55,7 @@ export interface IRouter {
|
||||
// toRedPill(): void;
|
||||
// toworkInProgress(): void;
|
||||
page(): Page;
|
||||
allowRouting(value: boolean): void;
|
||||
toActiveScripts(): void;
|
||||
toAugmentations(): void;
|
||||
toBitVerse(flume: boolean, quick: boolean): void;
|
||||
@ -84,4 +86,5 @@ export interface IRouter {
|
||||
toStaneksGift(): void;
|
||||
toAchievements(): void;
|
||||
toThemeBrowser(): void;
|
||||
toImportSave(base64Save: string, automatic?: boolean): void;
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user