mirror of
https://github.com/bitburner-official/bitburner-src.git
synced 2024-11-29 19:13: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
|
// 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.
|
// here. Hey if it works it works.
|
||||||
const steamAchievements = greenworks.getAchievementNames();
|
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);
|
const playerAchieved = (await Promise.all(steamAchievements.map(checkSteamAchievement))).filter(name => !!name);
|
||||||
log.debug(`Player has Steam achievements ${JSON.stringify(playerAchieved)}`);
|
log.debug(`Player has Steam achievements ${JSON.stringify(playerAchieved)}`);
|
||||||
const intervalID = setInterval(async () => {
|
const intervalID = setInterval(async () => {
|
||||||
|
@ -27,6 +27,7 @@ async function createWindow(killall) {
|
|||||||
backgroundColor: "#000000",
|
backgroundColor: "#000000",
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
nativeWindowOpen: true,
|
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 no-process-exit */
|
||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
/* 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 log = require("electron-log");
|
||||||
const greenworks = require("./greenworks");
|
const greenworks = require("./greenworks");
|
||||||
const api = require("./api-server");
|
const api = require("./api-server");
|
||||||
const gameWindow = require("./gameWindow");
|
const gameWindow = require("./gameWindow");
|
||||||
const achievements = require("./achievements");
|
const achievements = require("./achievements");
|
||||||
const utils = require("./utils");
|
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.catchErrors();
|
||||||
log.info(`Started app: ${JSON.stringify(process.argv)}`);
|
log.info(`Started app: ${JSON.stringify(process.argv)}`);
|
||||||
@ -30,6 +37,8 @@ try {
|
|||||||
global.greenworksError = ex.message;
|
global.greenworksError = ex.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let isRestoreDisabled = false;
|
||||||
|
|
||||||
function setStopProcessHandler(app, window, enabled) {
|
function setStopProcessHandler(app, window, enabled) {
|
||||||
const closingWindowHandler = async (e) => {
|
const closingWindowHandler = async (e) => {
|
||||||
// We need to prevent the default closing event to add custom logic
|
// 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
|
// Shutdown the http server
|
||||||
api.disable();
|
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,
|
// 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.
|
// 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.
|
// So we'll alert the player to close their browser.
|
||||||
@ -87,13 +108,98 @@ function setStopProcessHandler(app, window, enabled) {
|
|||||||
process.exit(0);
|
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) {
|
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("closed", clearWindowHandler);
|
||||||
window.on("close", closingWindowHandler)
|
window.on("close", closingWindowHandler)
|
||||||
app.on("window-all-closed", stopProcessHandler);
|
app.on("window-all-closed", stopProcessHandler);
|
||||||
} else {
|
} else {
|
||||||
log.debug('Removing closing handlers');
|
log.debug("Removing closing handlers");
|
||||||
|
ipcMain.removeAllListeners();
|
||||||
window.removeListener("closed", clearWindowHandler);
|
window.removeListener("closed", clearWindowHandler);
|
||||||
window.removeListener("close", closingWindowHandler);
|
window.removeListener("close", closingWindowHandler);
|
||||||
app.removeListener("window-all-closed", stopProcessHandler);
|
app.removeListener("window-all-closed", stopProcessHandler);
|
||||||
@ -110,7 +216,7 @@ global.app_handlers = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
log.info('Application is ready!');
|
log.info("Application is ready!");
|
||||||
|
|
||||||
if (process.argv.includes("--export-save")) {
|
if (process.argv.includes("--export-save")) {
|
||||||
const window = new BrowserWindow({ show: false });
|
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 */
|
/* 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 log = require("electron-log");
|
||||||
|
const Config = require("electron-config");
|
||||||
const api = require("./api-server");
|
const api = require("./api-server");
|
||||||
const utils = require("./utils");
|
const utils = require("./utils");
|
||||||
|
const storage = require("./storage");
|
||||||
|
const config = new Config();
|
||||||
|
|
||||||
function getMenu(window) {
|
function getMenu(window) {
|
||||||
return Menu.buildFromTemplate([
|
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",
|
label: "Edit",
|
||||||
submenu: [
|
submenu: [
|
||||||
@ -163,6 +321,17 @@ function getMenu(window) {
|
|||||||
label: "Activate",
|
label: "Activate",
|
||||||
click: () => window.webContents.openDevTools(),
|
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",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-config": "^2.0.0",
|
"electron-config": "^2.0.0",
|
||||||
"electron-log": "^4.4.4"
|
"electron-log": "^4.4.4",
|
||||||
|
"lodash": "^4.17.21"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/conf": {
|
"node_modules/conf": {
|
||||||
@ -104,6 +105,11 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/make-dir": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz",
|
||||||
@ -259,6 +265,11 @@
|
|||||||
"path-exists": "^3.0.0"
|
"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": {
|
"make-dir": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz",
|
||||||
|
@ -25,6 +25,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-config": "^2.0.0",
|
"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 { Player } from "./Player";
|
||||||
|
import { Router } from "./ui/GameRoot";
|
||||||
|
import { isScriptFilename } from "./Script/isScriptFilename";
|
||||||
|
import { Script } from "./Script/Script";
|
||||||
import { removeLeadingSlash } from "./Terminal/DirectoryHelpers";
|
import { removeLeadingSlash } from "./Terminal/DirectoryHelpers";
|
||||||
import { Terminal } from "./Terminal";
|
import { Terminal } from "./Terminal";
|
||||||
import { SnackbarEvents } from "./ui/React/Snackbar";
|
import { SnackbarEvents } from "./ui/React/Snackbar";
|
||||||
import { IMap, IReturnStatus } from "./types";
|
import { IMap, IReturnStatus } from "./types";
|
||||||
import { GetServer } from "./Server/AllServers";
|
import { GetServer } from "./Server/AllServers";
|
||||||
import { resolve } from "cypress/types/bluebird";
|
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 {
|
export function initElectron(): void {
|
||||||
const userAgent = navigator.userAgent.toLowerCase();
|
const userAgent = navigator.userAgent.toLowerCase();
|
||||||
@ -13,6 +21,8 @@ export function initElectron(): void {
|
|||||||
(document as any).achievements = [];
|
(document as any).achievements = [];
|
||||||
initWebserver();
|
initWebserver();
|
||||||
initAppNotifier();
|
initAppNotifier();
|
||||||
|
initSaveFunctions();
|
||||||
|
initElectronBridge();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,6 +119,123 @@ function initAppNotifier(): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Will be consumud by the electron wrapper.
|
// Will be consumud by the electron wrapper.
|
||||||
// @ts-ignore
|
(window as any).appNotifier = funcs;
|
||||||
window.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 { MoneySourceTracker } from "../../utils/MoneySourceTracker";
|
||||||
import { Reviver, Generic_toJSON, Generic_fromJSON } from "../../utils/JSONReviver";
|
import { Reviver, Generic_toJSON, Generic_fromJSON } from "../../utils/JSONReviver";
|
||||||
import { ISkillProgress } from "../formulas/skill";
|
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 {
|
export class PlayerObject implements IPlayer {
|
||||||
// Class members
|
// Class members
|
||||||
@ -78,7 +80,9 @@ export class PlayerObject implements IPlayer {
|
|||||||
exploits: Exploit[];
|
exploits: Exploit[];
|
||||||
achievements: PlayerAchievement[];
|
achievements: PlayerAchievement[];
|
||||||
terminalCommandHistory: string[];
|
terminalCommandHistory: string[];
|
||||||
|
identifier: string;
|
||||||
lastUpdate: number;
|
lastUpdate: number;
|
||||||
|
lastSave: number;
|
||||||
totalPlaytime: number;
|
totalPlaytime: number;
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
@ -460,7 +464,9 @@ export class PlayerObject implements IPlayer {
|
|||||||
|
|
||||||
//Used to store the last update time.
|
//Used to store the last update time.
|
||||||
this.lastUpdate = 0;
|
this.lastUpdate = 0;
|
||||||
|
this.lastSave = 0;
|
||||||
this.totalPlaytime = 0;
|
this.totalPlaytime = 0;
|
||||||
|
|
||||||
this.playtimeSinceLastAug = 0;
|
this.playtimeSinceLastAug = 0;
|
||||||
this.playtimeSinceLastBitnode = 0;
|
this.playtimeSinceLastBitnode = 0;
|
||||||
|
|
||||||
@ -474,6 +480,16 @@ export class PlayerObject implements IPlayer {
|
|||||||
this.achievements = [];
|
this.achievements = [];
|
||||||
this.terminalCommandHistory = [];
|
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.init = generalMethods.init;
|
||||||
this.prestigeAugmentation = generalMethods.prestigeAugmentation;
|
this.prestigeAugmentation = generalMethods.prestigeAugmentation;
|
||||||
this.prestigeSourceFile = generalMethods.prestigeSourceFile;
|
this.prestigeSourceFile = generalMethods.prestigeSourceFile;
|
||||||
|
@ -23,11 +23,43 @@ import { AugmentationNames } from "./Augmentation/data/AugmentationNames";
|
|||||||
import { PlayerOwnedAugmentation } from "./Augmentation/PlayerOwnedAugmentation";
|
import { PlayerOwnedAugmentation } from "./Augmentation/PlayerOwnedAugmentation";
|
||||||
import { LocationName } from "./Locations/data/LocationNames";
|
import { LocationName } from "./Locations/data/LocationNames";
|
||||||
import { SxProps } from "@mui/system";
|
import { SxProps } from "@mui/system";
|
||||||
|
import { PlayerObject } from "./PersonObjects/Player/PlayerObject";
|
||||||
|
import { pushGameSaved } from "./Electron";
|
||||||
|
|
||||||
/* SaveObject.js
|
/* SaveObject.js
|
||||||
* Defines the object used to save/load games
|
* 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 {
|
class BitburnerSaveObject {
|
||||||
PlayerSave = "";
|
PlayerSave = "";
|
||||||
AllServersSave = "";
|
AllServersSave = "";
|
||||||
@ -42,7 +74,6 @@ class BitburnerSaveObject {
|
|||||||
AllGangsSave = "";
|
AllGangsSave = "";
|
||||||
LastExportBonus = "";
|
LastExportBonus = "";
|
||||||
StaneksGiftSave = "";
|
StaneksGiftSave = "";
|
||||||
SaveTimestamp = "";
|
|
||||||
|
|
||||||
getSaveString(excludeRunningScripts = false): string {
|
getSaveString(excludeRunningScripts = false): string {
|
||||||
this.PlayerSave = JSON.stringify(Player);
|
this.PlayerSave = JSON.stringify(Player);
|
||||||
@ -58,7 +89,6 @@ class BitburnerSaveObject {
|
|||||||
this.VersionSave = JSON.stringify(CONSTANTS.VersionNumber);
|
this.VersionSave = JSON.stringify(CONSTANTS.VersionNumber);
|
||||||
this.LastExportBonus = JSON.stringify(ExportBonus.LastExportBonus);
|
this.LastExportBonus = JSON.stringify(ExportBonus.LastExportBonus);
|
||||||
this.StaneksGiftSave = JSON.stringify(staneksGift);
|
this.StaneksGiftSave = JSON.stringify(staneksGift);
|
||||||
this.SaveTimestamp = new Date().getTime().toString();
|
|
||||||
|
|
||||||
if (Player.inGang()) {
|
if (Player.inGang()) {
|
||||||
this.AllGangsSave = JSON.stringify(AllGangs);
|
this.AllGangsSave = JSON.stringify(AllGangs);
|
||||||
@ -68,28 +98,134 @@ class BitburnerSaveObject {
|
|||||||
return saveString;
|
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);
|
const saveString = this.getSaveString(Settings.ExcludeRunningScriptsFromSave);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
save(saveString)
|
save(saveString)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
const saveData: SaveData = {
|
||||||
|
playerIdentifier: Player.identifier,
|
||||||
|
fileName: this.getSaveFileName(),
|
||||||
|
save: saveString,
|
||||||
|
savedOn,
|
||||||
|
};
|
||||||
|
pushGameSaved(saveData);
|
||||||
|
|
||||||
if (emitToastEvent) {
|
if (emitToastEvent) {
|
||||||
SnackbarEvents.emit("Game Saved!", "info", 2000);
|
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 {
|
exportGame(): void {
|
||||||
const saveString = this.getSaveString(Settings.ExcludeRunningScriptsFromSave);
|
const saveString = this.getSaveString(Settings.ExcludeRunningScriptsFromSave);
|
||||||
|
const filename = this.getSaveFileName();
|
||||||
// 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`;
|
|
||||||
download(filename, saveString);
|
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 {
|
toJSON(): any {
|
||||||
return Generic_toJSON("BitburnerSaveObject", this);
|
return Generic_toJSON("BitburnerSaveObject", this);
|
||||||
}
|
}
|
||||||
|
@ -6,22 +6,8 @@ import { isScriptFilename } from "../../Script/isScriptFilename";
|
|||||||
import FileSaver from "file-saver";
|
import FileSaver from "file-saver";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
|
|
||||||
export function download(
|
export function exportScripts(pattern: string, server: BaseServer): void {
|
||||||
terminal: ITerminal,
|
const matchEnding = pattern.length == 1 || pattern === "*.*" ? null : pattern.slice(1); // Treat *.* the same as *
|
||||||
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 *
|
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
// Helper function to zip any file contents whose name matches the pattern
|
// Helper function to zip any file contents whose name matches the pattern
|
||||||
const zipFiles = (fileNames: string[], fileContents: string[]): void => {
|
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.fn),
|
||||||
server.textFiles.map((s) => s.text),
|
server.textFiles.map((s) => s.text),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Return an error if no files matched, rather than an empty zip folder
|
// 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}`);
|
if (Object.keys(zip.files).length == 0) throw new Error(`No files match the pattern ${pattern}`);
|
||||||
const zipFn = `bitburner${isScriptFilename(fn) ? "Scripts" : fn === "*.txt" ? "Texts" : "Files"}.zip`;
|
const zipFn = `bitburner${isScriptFilename(pattern) ? "Scripts" : pattern === "*.txt" ? "Texts" : "Files"}.zip`;
|
||||||
zip.generateAsync({ type: "blob" }).then((content: any) => FileSaver.saveAs(content, zipFn));
|
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;
|
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)) {
|
} else if (isScriptFilename(fn)) {
|
||||||
// Download a single script
|
// Download a single script
|
||||||
const script = terminal.getScript(player, fn);
|
const script = terminal.getScript(player, fn);
|
||||||
|
@ -81,6 +81,11 @@ import { AchievementsRoot } from "../Achievements/AchievementsRoot";
|
|||||||
import { ErrorBoundary } from "./ErrorBoundary";
|
import { ErrorBoundary } from "./ErrorBoundary";
|
||||||
import { Settings } from "../Settings/Settings";
|
import { Settings } from "../Settings/Settings";
|
||||||
import { ThemeBrowser } from "../Themes/ui/ThemeBrowser";
|
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;
|
const htmlLocation = location;
|
||||||
|
|
||||||
@ -109,6 +114,9 @@ export let Router: IRouter = {
|
|||||||
page: () => {
|
page: () => {
|
||||||
throw new Error("Router called before initialization");
|
throw new Error("Router called before initialization");
|
||||||
},
|
},
|
||||||
|
allowRouting: () => {
|
||||||
|
throw new Error("Router called before initialization");
|
||||||
|
},
|
||||||
toActiveScripts: () => {
|
toActiveScripts: () => {
|
||||||
throw new Error("Router called before initialization");
|
throw new Error("Router called before initialization");
|
||||||
},
|
},
|
||||||
@ -199,6 +207,9 @@ export let Router: IRouter = {
|
|||||||
toThemeBrowser: () => {
|
toThemeBrowser: () => {
|
||||||
throw new Error("Router called before initialization");
|
throw new Error("Router called before initialization");
|
||||||
},
|
},
|
||||||
|
toImportSave: () => {
|
||||||
|
throw new Error("Router called before initialization");
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function determineStartPage(player: IPlayer): Page {
|
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 [errorBoundaryKey, setErrorBoundaryKey] = useState<number>(0);
|
||||||
const [sidebarOpened, setSideBarOpened] = useState(Settings.IsSidebarOpened);
|
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 {
|
function resetErrorBoundary(): void {
|
||||||
setErrorBoundaryKey(errorBoundaryKey + 1);
|
setErrorBoundaryKey(errorBoundaryKey + 1);
|
||||||
}
|
}
|
||||||
@ -249,6 +267,7 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme
|
|||||||
|
|
||||||
Router = {
|
Router = {
|
||||||
page: () => page,
|
page: () => page,
|
||||||
|
allowRouting: (value: boolean) => setAllowRoutingCalls(value),
|
||||||
toActiveScripts: () => setPage(Page.ActiveScripts),
|
toActiveScripts: () => setPage(Page.ActiveScripts),
|
||||||
toAugmentations: () => setPage(Page.Augmentations),
|
toAugmentations: () => setPage(Page.Augmentations),
|
||||||
toBladeburner: () => setPage(Page.Bladeburner),
|
toBladeburner: () => setPage(Page.Bladeburner),
|
||||||
@ -315,9 +334,34 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme
|
|||||||
},
|
},
|
||||||
toThemeBrowser: () => {
|
toThemeBrowser: () => {
|
||||||
setPage(Page.ThemeBrowser);
|
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(() => {
|
useEffect(() => {
|
||||||
if (page !== Page.Terminal) window.scrollTo(0, 0);
|
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 mainPage = <Typography>Cannot load</Typography>;
|
||||||
let withSidebar = true;
|
let withSidebar = true;
|
||||||
let withPopups = true;
|
let withPopups = true;
|
||||||
|
let bypassGame = false;
|
||||||
switch (page) {
|
switch (page) {
|
||||||
case Page.Recovery: {
|
case Page.Recovery: {
|
||||||
mainPage = <RecoveryRoot router={Router} softReset={softReset} />;
|
mainPage = <RecoveryRoot router={Router} softReset={softReset} />;
|
||||||
withSidebar = false;
|
withSidebar = false;
|
||||||
withPopups = false;
|
withPopups = false;
|
||||||
|
bypassGame = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case Page.BitVerse: {
|
case Page.BitVerse: {
|
||||||
@ -517,12 +563,25 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme
|
|||||||
mainPage = <ThemeBrowser router={Router} />;
|
mainPage = <ThemeBrowser router={Router} />;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case Page.ImportSave: {
|
||||||
|
mainPage = (
|
||||||
|
<ImportSaveRoot
|
||||||
|
importString={importString}
|
||||||
|
automatic={importAutomatic}
|
||||||
|
router={Router}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
withSidebar = false;
|
||||||
|
withPopups = false;
|
||||||
|
bypassGame = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Context.Player.Provider value={player}>
|
<Context.Player.Provider value={player}>
|
||||||
<Context.Router.Provider value={Router}>
|
<Context.Router.Provider value={Router}>
|
||||||
<ErrorBoundary key={errorBoundaryKey} router={Router} softReset={softReset}>
|
<ErrorBoundary key={errorBoundaryKey} router={Router} softReset={softReset}>
|
||||||
|
<BypassWrapper content={bypassGame ? mainPage : null}>
|
||||||
<SnackbarProvider>
|
<SnackbarProvider>
|
||||||
<Overview mode={ITutorial.isRunning ? "tutorial" : "overview"}>
|
<Overview mode={ITutorial.isRunning ? "tutorial" : "overview"}>
|
||||||
{!ITutorial.isRunning ? (
|
{!ITutorial.isRunning ? (
|
||||||
@ -533,12 +592,16 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme
|
|||||||
</Overview>
|
</Overview>
|
||||||
{withSidebar ? (
|
{withSidebar ? (
|
||||||
<Box display="flex" flexDirection="row" width="100%">
|
<Box display="flex" flexDirection="row" width="100%">
|
||||||
<SidebarRoot player={player} router={Router} page={page}
|
<SidebarRoot
|
||||||
|
player={player}
|
||||||
|
router={Router}
|
||||||
|
page={page}
|
||||||
opened={sidebarOpened}
|
opened={sidebarOpened}
|
||||||
onToggled={(isOpened) => {
|
onToggled={(isOpened) => {
|
||||||
setSideBarOpened(isOpened);
|
setSideBarOpened(isOpened);
|
||||||
Settings.IsSidebarOpened = isOpened;
|
Settings.IsSidebarOpened = isOpened;
|
||||||
}} />
|
}}
|
||||||
|
/>
|
||||||
<Box className={classes.root}>{mainPage}</Box>
|
<Box className={classes.root}>{mainPage}</Box>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
@ -555,6 +618,7 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SnackbarProvider>
|
</SnackbarProvider>
|
||||||
|
</BypassWrapper>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</Context.Router.Provider>
|
</Context.Router.Provider>
|
||||||
</Context.Player.Provider>
|
</Context.Player.Provider>
|
||||||
|
@ -16,6 +16,7 @@ import { GameRoot } from "./GameRoot";
|
|||||||
import { CONSTANTS } from "../Constants";
|
import { CONSTANTS } from "../Constants";
|
||||||
import { ActivateRecoveryMode } from "./React/RecoveryRoot";
|
import { ActivateRecoveryMode } from "./React/RecoveryRoot";
|
||||||
import { hash } from "../hash/hash";
|
import { hash } from "../hash/hash";
|
||||||
|
import { pushGameReady } from "../Electron";
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) =>
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
createStyles({
|
createStyles({
|
||||||
@ -56,6 +57,7 @@ export function LoadingScreen(): React.ReactElement {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pushGameReady();
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
})
|
})
|
||||||
.catch((reason) => {
|
.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;
|
onClose: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
confirmationText: string | React.ReactNode;
|
confirmationText: string | React.ReactNode;
|
||||||
|
additionalButton?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfirmationModal(props: IProps): React.ReactElement {
|
export function ConfirmationModal(props: IProps): React.ReactElement {
|
||||||
@ -23,6 +24,7 @@ export function ConfirmationModal(props: IProps): React.ReactElement {
|
|||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</Button>
|
</Button>
|
||||||
|
{props.additionalButton && <>{props.additionalButton}</>}
|
||||||
</>
|
</>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
@ -5,6 +5,7 @@ import Button from "@mui/material/Button";
|
|||||||
import { Tooltip } from '@mui/material';
|
import { Tooltip } from '@mui/material';
|
||||||
|
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import { pushDisableRestore } from '../../Electron';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
color?: "primary" | "warning" | "error";
|
color?: "primary" | "warning" | "error";
|
||||||
@ -21,7 +22,10 @@ export function DeleteGameButton({ color = "primary" }: IProps): React.ReactElem
|
|||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
setModalOpened(false);
|
setModalOpened(false);
|
||||||
deleteGame()
|
deleteGame()
|
||||||
.then(() => setTimeout(() => location.reload(), 1000))
|
.then(() => {
|
||||||
|
pushDisableRestore();
|
||||||
|
setTimeout(() => location.reload(), 1000);
|
||||||
|
})
|
||||||
.catch((r) => console.error(`Could not delete game: ${r}`));
|
.catch((r) => console.error(`Could not delete game: ${r}`));
|
||||||
}}
|
}}
|
||||||
open={modalOpened}
|
open={modalOpened}
|
||||||
|
@ -25,20 +25,20 @@ import SaveIcon from "@mui/icons-material/Save";
|
|||||||
import PaletteIcon from '@mui/icons-material/Palette';
|
import PaletteIcon from '@mui/icons-material/Palette';
|
||||||
|
|
||||||
import { FileDiagnosticModal } from "../../Diagnostic/FileDiagnosticModal";
|
import { FileDiagnosticModal } from "../../Diagnostic/FileDiagnosticModal";
|
||||||
import { dialogBoxCreate } from "./DialogBox";
|
|
||||||
import { ConfirmationModal } from "./ConfirmationModal";
|
import { ConfirmationModal } from "./ConfirmationModal";
|
||||||
|
|
||||||
import { SnackbarEvents } from "./Snackbar";
|
import { SnackbarEvents } from "./Snackbar";
|
||||||
|
|
||||||
import { Settings } from "../../Settings/Settings";
|
import { Settings } from "../../Settings/Settings";
|
||||||
import { save } from "../../db";
|
|
||||||
import { formatTime } from "../../utils/helpers/formatTime";
|
|
||||||
import { OptionSwitch } from "./OptionSwitch";
|
|
||||||
import { DeleteGameButton } from "./DeleteGameButton";
|
import { DeleteGameButton } from "./DeleteGameButton";
|
||||||
import { SoftResetButton } from "./SoftResetButton";
|
import { SoftResetButton } from "./SoftResetButton";
|
||||||
import { IRouter } from "../Router";
|
import { IRouter } from "../Router";
|
||||||
import { ThemeEditorButton } from "../../Themes/ui/ThemeEditorButton";
|
import { ThemeEditorButton } from "../../Themes/ui/ThemeEditorButton";
|
||||||
import { StyleEditorButton } from "../../Themes/ui/StyleEditorButton";
|
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) =>
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
createStyles({
|
createStyles({
|
||||||
@ -59,12 +59,6 @@ interface IProps {
|
|||||||
softReset: () => void;
|
softReset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportData {
|
|
||||||
base64: string;
|
|
||||||
parsed: any;
|
|
||||||
exportDate?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameOptionsRoot(props: IProps): React.ReactElement {
|
export function GameOptionsRoot(props: IProps): React.ReactElement {
|
||||||
const classes = useStyles();
|
const classes = useStyles();
|
||||||
const importInput = useRef<HTMLInputElement>(null);
|
const importInput = useRef<HTMLInputElement>(null);
|
||||||
@ -122,78 +116,35 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
|
|||||||
ii.click();
|
ii.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onImport(event: React.ChangeEvent<HTMLInputElement>): void {
|
async function onImport(event: React.ChangeEvent<HTMLInputElement>): Promise<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 {
|
try {
|
||||||
newSave = window.atob(contents);
|
const base64Save = await saveObject.getImportStringFromFile(event.target.files);
|
||||||
newSave = newSave.trim();
|
const data = await saveObject.getImportDataFromString(base64Save);
|
||||||
} catch (error) {
|
|
||||||
console.log(error); // We'll handle below
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!newSave || newSave === "") {
|
|
||||||
SnackbarEvents.emit("Save game had not content or was not base64 encoded", "error", 5000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsedSave;
|
|
||||||
try {
|
|
||||||
parsedSave = JSON.parse(newSave);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error); // We'll handle below
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parsedSave || parsedSave.ctor !== "BitburnerSaveObject" || !parsedSave.data) {
|
|
||||||
SnackbarEvents.emit("Save game did not seem valid", "error", 5000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: ImportData = {
|
|
||||||
base64: contents,
|
|
||||||
parsed: parsedSave,
|
|
||||||
};
|
|
||||||
|
|
||||||
const timestamp = parsedSave.data.SaveTimestamp;
|
|
||||||
if (timestamp && timestamp !== "0") {
|
|
||||||
data.exportDate = new Date(parseInt(timestamp, 10));
|
|
||||||
}
|
|
||||||
|
|
||||||
setImportData(data);
|
setImportData(data);
|
||||||
setImportSaveOpen(true);
|
setImportSaveOpen(true);
|
||||||
};
|
} catch (ex: any) {
|
||||||
reader.readAsText(file);
|
SnackbarEvents.emit(ex.toString(), "error", 5000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmedImportGame(): void {
|
async function confirmedImportGame(): Promise<void> {
|
||||||
if (!importData) return;
|
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);
|
setImportSaveOpen(false);
|
||||||
save(importData.base64).then(() => {
|
|
||||||
setImportData(null);
|
setImportData(null);
|
||||||
setTimeout(() => location.reload(), 1000);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -585,6 +536,7 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
|
|||||||
open={importSaveOpen}
|
open={importSaveOpen}
|
||||||
onClose={() => setImportSaveOpen(false)}
|
onClose={() => setImportSaveOpen(false)}
|
||||||
onConfirm={() => confirmedImportGame()}
|
onConfirm={() => confirmedImportGame()}
|
||||||
|
additionalButton={<Button onClick={compareSaveGame}>Compare Save</Button>}
|
||||||
confirmationText={
|
confirmationText={
|
||||||
<>
|
<>
|
||||||
Importing a new game will <strong>completely wipe</strong> the current data!
|
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.
|
Make sure to have a backup of your current save file before importing.
|
||||||
<br />
|
<br />
|
||||||
The file you are attempting to import seems valid.
|
The file you are attempting to import seems valid.
|
||||||
<br />
|
{(importData?.playerData?.lastSave ?? 0) > 0 && (
|
||||||
<br />
|
|
||||||
{importData?.exportDate && (
|
|
||||||
<>
|
<>
|
||||||
The export date of the save file is <strong>{importData?.exportDate.toString()}</strong>
|
|
||||||
<br />
|
<br />
|
||||||
<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,
|
Recovery,
|
||||||
Achievements,
|
Achievements,
|
||||||
ThemeBrowser,
|
ThemeBrowser,
|
||||||
|
ImportSave,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScriptEditorRouteOptions {
|
export interface ScriptEditorRouteOptions {
|
||||||
@ -54,6 +55,7 @@ export interface IRouter {
|
|||||||
// toRedPill(): void;
|
// toRedPill(): void;
|
||||||
// toworkInProgress(): void;
|
// toworkInProgress(): void;
|
||||||
page(): Page;
|
page(): Page;
|
||||||
|
allowRouting(value: boolean): void;
|
||||||
toActiveScripts(): void;
|
toActiveScripts(): void;
|
||||||
toAugmentations(): void;
|
toAugmentations(): void;
|
||||||
toBitVerse(flume: boolean, quick: boolean): void;
|
toBitVerse(flume: boolean, quick: boolean): void;
|
||||||
@ -84,4 +86,5 @@ export interface IRouter {
|
|||||||
toStaneksGift(): void;
|
toStaneksGift(): void;
|
||||||
toAchievements(): void;
|
toAchievements(): void;
|
||||||
toThemeBrowser(): void;
|
toThemeBrowser(): void;
|
||||||
|
toImportSave(base64Save: string, automatic?: boolean): void;
|
||||||
}
|
}
|
||||||
|
@ -97,4 +97,31 @@ function generateRandomString(n: number): string {
|
|||||||
return str;
|
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