Merge pull request #2781 from MartinFournier/feat/steam-saves

Add Steam Cloud saves & filesystem saves to Electron
This commit is contained in:
hydroflame 2022-01-26 12:56:16 -05:00 committed by GitHub
commit 26d5e4dae0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1591 additions and 166 deletions

@ -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'),
},
});

@ -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 });

@ -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);
}
}
}
],
},
]);

@ -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

@ -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

@ -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,
};

@ -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);
save(saveString)
.then(() => {
if (emitToastEvent) {
SnackbarEvents.emit("Game Saved!", "info", 2000);
}
})
.catch((err) => console.error(err));
if (emitToastEvent) {
SnackbarEvents.emit("Game Saved!", "info", 2000);
}
return resolve();
})
.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,6 +6,37 @@ import { isScriptFilename } from "../../Script/isScriptFilename";
import FileSaver from "file-saver";
import JSZip from "jszip";
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 => {
for (let i = 0; i < fileContents.length; ++i) {
let name = fileNames[i];
if (name.startsWith("/")) name = name.slice(1);
if (!matchEnding || name.endsWith(matchEnding))
zip.file(name, new Blob([fileContents[i]], { type: "text/plain" }));
}
};
// In the case of script files, we pull from the server.scripts array
if (!matchEnding || isScriptFilename(matchEnding))
zipFiles(
server.scripts.map((s) => s.filename),
server.scripts.map((s) => s.code),
);
// In the case of text files, we pull from the server.scripts array
if (!matchEnding || matchEnding.endsWith(".txt"))
zipFiles(
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) 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,
@ -21,34 +52,12 @@ export function download(
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 *
const zip = new JSZip();
// Helper function to zip any file contents whose name matches the pattern
const zipFiles = (fileNames: string[], fileContents: string[]): void => {
for (let i = 0; i < fileContents.length; ++i) {
let name = fileNames[i];
if (name.startsWith("/")) name = name.slice(1);
if (!matchEnding || name.endsWith(matchEnding))
zip.file(name, new Blob([fileContents[i]], { type: "text/plain" }));
}
};
// In the case of script files, we pull from the server.scripts array
if (!matchEnding || isScriptFilename(matchEnding))
zipFiles(
server.scripts.map((s) => s.filename),
server.scripts.map((s) => s.code),
);
// In the case of text files, we pull from the server.scripts array
if (!matchEnding || matchEnding.endsWith(".txt"))
zipFiles(
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`;
zip.generateAsync({ type: "blob" }).then((content: any) => FileSaver.saveAs(content, zipFn));
return;
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,44 +563,62 @@ 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}>
<SnackbarProvider>
<Overview mode={ITutorial.isRunning ? "tutorial" : "overview"}>
{!ITutorial.isRunning ? (
<CharacterOverview save={() => saveObject.saveGame()} killScripts={killAllScripts} />
<BypassWrapper content={bypassGame ? mainPage : null}>
<SnackbarProvider>
<Overview mode={ITutorial.isRunning ? "tutorial" : "overview"}>
{!ITutorial.isRunning ? (
<CharacterOverview save={() => saveObject.saveGame()} killScripts={killAllScripts} />
) : (
<InteractiveTutorialRoot />
)}
</Overview>
{withSidebar ? (
<Box display="flex" flexDirection="row" width="100%">
<SidebarRoot
player={player}
router={Router}
page={page}
opened={sidebarOpened}
onToggled={(isOpened) => {
setSideBarOpened(isOpened);
Settings.IsSidebarOpened = isOpened;
}}
/>
<Box className={classes.root}>{mainPage}</Box>
</Box>
) : (
<InteractiveTutorialRoot />
)}
</Overview>
{withSidebar ? (
<Box display="flex" flexDirection="row" width="100%">
<SidebarRoot player={player} router={Router} page={page}
opened={sidebarOpened}
onToggled={(isOpened) => {
setSideBarOpened(isOpened);
Settings.IsSidebarOpened = isOpened;
}} />
<Box className={classes.root}>{mainPage}</Box>
</Box>
) : (
<Box className={classes.root}>{mainPage}</Box>
)}
<Unclickable />
{withPopups && (
<>
<LogBoxManager />
<AlertManager />
<PromptManager />
<InvitationModal />
<Snackbar />
</>
)}
</SnackbarProvider>
)}
<Unclickable />
{withPopups && (
<>
<LogBoxManager />
<AlertManager />
<PromptManager />
<InvitationModal />
<Snackbar />
</>
)}
</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) => {

@ -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;
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));
}
async function onImport(event: React.ChangeEvent<HTMLInputElement>): Promise<void> {
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<void> {
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);
}
function compareSaveGame(): void {
if (!importData) return;
props.router.toImportSave(importData.base64);
setImportSaveOpen(false);
setImportData(null);
}
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 />
</>
}
/>

@ -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,
};