Merge branch 'dev' into add-ns-getRecentScripts

This commit is contained in:
smolgumball 2022-01-26 19:07:02 -07:00
commit 2add4e9112
183 changed files with 6613 additions and 36639 deletions

@ -1,27 +1,26 @@
node_modules/
dist/
input/
.dist
.tmp
.package
assets/
css/
.build
.cypress/
cypress/
dist/
input/
assets/
doc/
markdown/
netscript_tests/
scripts/
test/netscript/
electron/lib
electron/greenworks.js
src/ThirdParty/*
src/JSInterpreter.js
tools/engines-check/
test/*.bundle.*
editor.main.js
main.bundle.js
webpack.config.js
webpack.config-test.js

100
.github/workflows/bump-version.yml vendored Normal file

@ -0,0 +1,100 @@
name: Bump BitBurner Version
on:
workflow_dispatch:
inputs:
version:
description: 'Version (format: x.y.z)'
required: true
versionNumber:
description: 'Version Number (for saves migration)'
required: true
changelog:
description: 'Changelog (url that points to RAW markdown)'
default: ''
buildApp:
description: 'Include Application Build'
type: boolean
default: 'true'
required: true
buildDoc:
description: 'Include Documentation Build'
type: boolean
default: 'true'
required: true
prepareRelease:
description: 'Prepare Draft Release'
type: boolean
default: 'true'
required: true
jobs:
bumpVersion:
name: Bump Version
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Install pandoc dependency
run: sudo apt-get install -y pandoc
- name: Checkout repository
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Use Node.js 16.13.1
uses: actions/setup-node@v2
with:
node-version: 16.13.1
cache: 'npm'
- name: Install NPM dependencies for version updater
working-directory: ./tools/bump-version
run: npm ci
- name: Bump version & update changelogs
working-directory: ./tools/bump-version
run: |
curl ${{ github.event.inputs.changelog }} > changes.md
node index.js --version=${{ github.event.inputs.version }} --versionNumber=${{ github.event.inputs.versionNumber }} < changes.md
- name: Install NPM dependencies for app
if: ${{ github.event.inputs.buildApp == 'true' || github.event.inputs.buildDoc == 'true' }}
run: npm ci
- name: Build Production App
if: ${{ github.event.inputs.buildApp == 'true' }}
run: npm run build
- name: Build Documentation
if: ${{ github.event.inputs.buildDoc == 'true' }}
run: npm run doc
- name: Commit Files
run: |
git config --global user.name "GitHub"
git config --global user.email "noreply@github.com"
git checkout -b bump/v${{ github.event.inputs.version }}
git add -A
echo "Bump version to v${{ github.event.inputs.version }}" > commitmessage.txt
echo "" >> commitmessage.txt
cat ./tools/bump-version/changes.md >> commitmessage.txt
git commit -F commitmessage.txt
git push -u origin bump/v${{ github.event.inputs.version }}
- name: Create Pull Request
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr create \
--base "${{ github.ref_name }}" \
--head "bump/v${{ github.event.inputs.version }}" \
--title "Bump version to v${{ github.event.inputs.version }}" \
--body-file ./tools/bump-version/changes.md
- name: Prepare release
if: ${{ github.event.inputs.prepareRelease == 'true' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RELEASE_TITLE="$(head -n 1 ./tools/bump-version/changes.md | sed 's/## //')"
RELEASE_TITLE="${RELEASE_TITLE:-v${{ github.event.inputs.version }}}"
gh release create \
v${{ github.event.inputs.version }} \
--target dev \
--title "$RELEASE_TITLE" \
--notes-file ./tools/bump-version/changes.md \
--generate-notes \
--draft

44
.github/workflows/fetch-changes.yml vendored Normal file

@ -0,0 +1,44 @@
name: Fetch Merged Changes
on:
workflow_dispatch:
inputs:
fromCommit:
description: 'From Commit SHA (full-length)'
required: true
toCommit:
description: 'To Commit SHA (full-length, if omitted will use latest)'
jobs:
fetchChangelog:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node v16.13.1
uses: actions/setup-node@v2
with:
node-version: 16.13.1
cache: 'npm'
- name: Install NPM dependencies
working-directory: ./tools/fetch-changelog
run: npm ci
- name: Fetch Changes from GitHub API
working-directory: ./tools/fetch-changelog
env:
GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
node index.js \
--from=${{ github.event.inputs.fromCommit }} \
--to=${{ github.event.inputs.toCommit }} > changes.md
echo
echo "============================================================"
echo
cat changes.md
echo
echo "============================================================"
echo
echo "You may want to go to https://gist.github.com/ to upload the final changelog"
echo "The next step will require an url because we can't easily pass multiline strings to actions"
- uses: actions/upload-artifact@v2
with:
name: bitburner_changelog___DRAFT.md
path: ./tools/fetch-changelog/changes.md

2
.gitignore vendored

@ -5,10 +5,10 @@ Netburner.txt
/doc/build
/node_modules
/electron/node_modules
/dist/*.map
/test/*.map
/test/*.bundle.*
/test/*.css
/input/bitburner.api.json
.cypress
# tmp folder for electron

@ -6,7 +6,7 @@ Bitburner is a programming-based [incremental game](https://en.wikipedia.org/wik
that revolves around hacking and cyberpunk themes.
The game can be played at https://danielyxie.github.io/bitburner or installed through [Steam](https://store.steampowered.com/app/1812820/Bitburner/).
See the [frequently asked questions](./FAQ.md) for more information . To discuss the game or get help, join the [official discord server](https://discord.gg/TFc3hKD)
See the [frequently asked questions](./doc/FAQ.md) for more information . To discuss the game or get help, join the [official discord server](https://discord.gg/TFc3hKD)
# Documentation
@ -18,13 +18,13 @@ The [in-game documentation](./markdown/bitburner.md) is generated from the [Type
Anyone is welcome to contribute to the documentation by editing the [source
files](/doc/source) and then making a pull request with your contributions.
For further guidance, please refer to the "As A Documentor" section of
[CONTRIBUTING](CONTRIBUTING.md).
[CONTRIBUTING](./doc/CONTRIBUTING.md).
# Contribution
There are many ways to contribute to the game. It can be as simple as fixing
a typo, correcting a bug, or improving the UI. For guidance on doing so,
please refer to the [CONTRIBUTING](CONTRIBUTING.md) document.
please refer to the [CONTRIBUTING](./doc/CONTRIBUTING.md) document.
You will retain all ownership of the Copyright of any contributions you make,
and will have the same rights to use or license your contributions. By

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@ -1,10 +1,14 @@
{
"baseUrl": "http://localhost:8000",
"fixturesFolder": false,
"trashAssetsBeforeRuns": true,
"screenshotsFolder": ".cypress/screenshots",
"videosFolder": ".cypress/videos",
"videoUploadOnPasses": false,
"viewportWidth": 1980,
"viewportHeight": 1080
"viewportHeight": 1080,
"fixturesFolder": "test/cypress/fixtures",
"integrationFolder": "test/cypress/integration",
"pluginsFile": "test/cypress/plugins/index.js",
"supportFile": "test/cypress/support/index.js",
"screenshotsFolder": ".cypress/screenshots",
"videosFolder": ".cypress/videos",
"downloadsFolder": ".cypress/downloads"
}

@ -1,9 +0,0 @@
export {};
beforeEach(() => {
cy.visit("/");
cy.clearLocalStorage();
cy.window().then((win) => {
win.indexedDB.deleteDatabase("bitburnerSave");
});
});

BIN
dist/favicon.ico vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

20
dist/main.bundle.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/main.bundle.js.map vendored Normal file

File diff suppressed because one or more lines are too long

36
dist/vendor.bundle.js vendored

File diff suppressed because one or more lines are too long

1
dist/vendor.bundle.js.map vendored Normal file

File diff suppressed because one or more lines are too long

@ -182,7 +182,7 @@ To contribute to and view your changes to the BitBurner documentation on [Read T
Docs](http://bitburner.readthedocs.io/), you will
need to have Python installed, along with [Sphinx](http://www.sphinx-doc.org).
To make change to the [in-game documentation](./markdown/bitburner.md), you will need to modify the [TypeScript definitions](./src/ScriptEditor/NetscriptDefinitions.d.ts), not the markdown files.
To make change to the [in-game documentation](../markdown/bitburner.md), you will need to modify the [TypeScript definitions](../src/ScriptEditor/NetscriptDefinitions.d.ts), not the markdown files.
We are using [API Extractor](https://api-extractor.com/pages/tsdoc/doc_comment_syntax/) (tsdoc hints) to generate the markdown doc. Make your changes to the TypeScript definitions and then run `npm run doc`.

@ -30,6 +30,11 @@ from faction to faction.
List of Factions and their Requirements
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. raw:: html
<details><summary><a>Early Game Factions</a></summary>
.. _gameplay_factions::
+---------------------+----------------+-----------------------------------------+-------------------------------+
| Early Game | Faction Name | Requirements | Joining this Faction prevents |
@ -46,7 +51,18 @@ List of Factions and their Requirements
| | | * Total Hacknet RAM of 8 | |
| | | * Total Hacknet Cores of 4 | |
+---------------------+----------------+-----------------------------------------+-------------------------------+
| City Factions | Sector-12 | * Be in Sector-12 | * Chongqing |
.. raw:: html
</details>
<details><summary><a>City Factions</a></summary>
.. _gameplay_factions::
+---------------------+----------------+-----------------------------------------+-------------------------------+
| City Factions | Faction Name | Requirements | Joining this Faction prevents |
| | | | you from joining: |
+ +----------------+-----------------------------------------+-------------------------------+
| | Sector-12 | * Be in Sector-12 | * Chongqing |
| | | * $15m | * New Tokyo |
| | | | * Ishima |
| | | | * Volhaven |
@ -74,8 +90,19 @@ List of Factions and their Requirements
| | | | * New Tokyo |
| | | | * Ishima |
+---------------------+----------------+-----------------------------------------+-------------------------------+
| Hacking | NiteSec | * Install a backdoor on the avmnite-02h | |
| Groups | | server | |
.. raw:: html
</details>
<details><summary><a>Hacking Groups</a></summary>
.. _gameplay_factions::
+---------------------+----------------+-----------------------------------------+-------------------------------+
| Hacking | Faction Name | Requirements | Joining this Faction prevents |
| Groups | | | you from joining: |
+ +----------------+-----------------------------------------+-------------------------------+
| | NiteSec | * Install a backdoor on the avmnite-02h | |
| | | server | |
| | | | |
+ +----------------+-----------------------------------------+-------------------------------+
| | The Black Hand | * Install a backdoor on the I.I.I.I | |
@ -86,7 +113,18 @@ List of Factions and their Requirements
| | | server | |
| | | | |
+---------------------+----------------+-----------------------------------------+-------------------------------+
| Megacorporations | ECorp | * Have 200k reputation with | |
.. raw:: html
</details>
<details><summary><a>Megacorporations</a></summary>
.. _gameplay_factions::
+---------------------+----------------+-----------------------------------------+-------------------------------+
| Megacorporations | Faction Name | Requirements | Joining this Faction prevents |
| | | | you from joining: |
+ +----------------+-----------------------------------------+-------------------------------+
| | ECorp | * Have 200k reputation with | |
| | | the Corporation | |
+ +----------------+-----------------------------------------+-------------------------------+
| | MegaCorp | * Have 200k reputation with | |
@ -118,8 +156,19 @@ List of Factions and their Requirements
| | | * Install a backdoor on the | |
| | | fulcrumassets server | |
+---------------------+----------------+-----------------------------------------+-------------------------------+
| Criminal | Slum Snakes | * All Combat Stats of 30 | |
| Organizations | | * -9 Karma | |
.. raw:: html
</details>
<details><summary><a>Criminal Organizations</a></summary>
.. _gameplay_factions::
+---------------------+----------------+-----------------------------------------+-------------------------------+
| Criminal | Faction Name | Requirements | Joining this Faction prevents |
| Organizations | | | you from joining: |
+ +----------------+-----------------------------------------+-------------------------------+
| | Slum Snakes | * All Combat Stats of 30 | |
| | | * -9 Karma | |
| | | * $1m | |
+ +----------------+-----------------------------------------+-------------------------------+
| | Tetrads | * Be in Chongqing, New Tokyo, or Ishima | |
@ -150,8 +199,19 @@ List of Factions and their Requirements
| | | * -90 Karma | |
| | | * Not working for CIA or NSA | |
+---------------------+----------------+-----------------------------------------+-------------------------------+
| Endgame | The Covenant | * 20 Augmentations | |
| Factions | | * $75b | |
.. raw:: html
</details>
<details><summary><a>Endgame Factions</a></summary>
.. _gameplay_factions::
+---------------------+----------------+-----------------------------------------+-------------------------------+
| Endgame | Faction Name | Requirements | Joining this Faction prevents |
| Factions | | | you from joining: |
+ +----------------+-----------------------------------------+-------------------------------+
| | The Covenant | * 20 Augmentations | |
| | | * $75b | |
| | | * Hacking Level of 850 | |
| | | * All Combat Stats of 850 | |
+ +----------------+-----------------------------------------+-------------------------------+
@ -165,3 +225,6 @@ List of Factions and their Requirements
| | | * Hacking Level of 1500 | |
| | | * All Combat Stats of 1200 | |
+---------------------+----------------+-----------------------------------------+-------------------------------+
.. raw:: html
</details><br>

@ -23,4 +23,4 @@ Bug
---
Otherwise, the game is probably frozen/stuck due to a bug. To report a bug, follow
the guidelines `here <https://github.com/danielyxie/bitburner/blob/master/CONTRIBUTING.md#reporting-bugs>`_.
the guidelines `here <https://github.com/danielyxie/bitburner/blob/master/doc/CONTRIBUTING.md#reporting-bugs>`_.

@ -9,7 +9,7 @@ async function enableAchievementsInterval(window) {
// This is backward but the game fills in an array called `document.achievements` and we retrieve it from
// here. Hey if it works it works.
const steamAchievements = greenworks.getAchievementNames();
log.debug(`All Steam achievements ${JSON.stringify(steamAchievements)}`);
log.silly(`All Steam achievements ${JSON.stringify(steamAchievements)}`);
const playerAchieved = (await Promise.all(steamAchievements.map(checkSteamAchievement))).filter(name => !!name);
log.debug(`Player has Steam achievements ${JSON.stringify(playerAchieved)}`);
const intervalID = setInterval(async () => {

@ -12,11 +12,13 @@ async function initialize(win) {
window = win;
server = http.createServer(async function (req, res) {
let body = "";
res.setHeader('Content-Type', 'application/json');
req.on("data", (chunk) => {
body += chunk.toString(); // convert Buffer to string
});
req.on("end", () => {
req.on("end", async () => {
const providedToken = req.headers?.authorization?.replace('Bearer ', '') ?? '';
const isValid = providedToken === getAuthenticationToken();
if (isValid) {
@ -24,8 +26,11 @@ async function initialize(win) {
} else {
log.log('Invalid authentication token');
res.writeHead(401);
res.write('Invalid authentication token');
res.end();
res.end(JSON.stringify({
success: false,
msg: 'Invalid authentication token'
}));
return;
}
@ -35,17 +40,56 @@ async function initialize(win) {
} catch (error) {
log.warn(`Invalid body data`);
res.writeHead(400);
res.write('Invalid body data');
res.end();
res.end(JSON.stringify({
success: false,
msg: 'Invalid body data'
}));
return;
}
if (data) {
window.webContents.executeJavaScript(`document.saveFile("${data.filename}", "${data.code}")`).then((result) => {
res.write(result);
res.end();
});
let result;
switch(req.method) {
// Request files
case "GET":
result = await window.webContents.executeJavaScript(`document.getFiles()`);
break;
// Create or update files
// Support POST for VScode implementation
case "POST":
case "PUT":
if (!data) {
log.warn(`Invalid script update request - No data`);
res.writeHead(400);
res.end(JSON.stringify({
success: false,
msg: 'Invalid script update request - No data'
}));
return;
}
result = await window.webContents.executeJavaScript(`document.saveFile("${data.filename}", "${data.code}")`);
break;
// Delete files
case "DELETE":
result = await window.webContents.executeJavaScript(`document.deleteFile("${data.filename}")`);
break;
}
if (!result.res) {
//We've encountered an error
res.writeHead(400);
log.warn(`Api Server Error`, result.msg);
}
res.end(JSON.stringify({
success: result.res,
msg: result.msg,
data: result.data
}));
});
});

@ -8,20 +8,42 @@ const api = require("./api-server");
const cp = require("child_process");
const path = require("path");
const fs = require("fs");
const { windowTracker } = require("./windowTracker");
const { fileURLToPath } = require("url");
const debug = process.argv.includes("--debug");
async function createWindow(killall) {
const setStopProcessHandler = global.app_handlers.stopProcess;
let icon;
if (process.platform == 'linux') {
icon = path.join(__dirname, 'icon.png');
}
const tracker = windowTracker('main');
const window = new BrowserWindow({
icon,
show: false,
backgroundThrottling: false,
backgroundColor: "#000000",
title: 'Bitburner',
x: tracker.state.x,
y: tracker.state.y,
width: tracker.state.width,
height: tracker.state.height,
minWidth: 600,
minHeight: 400,
webPreferences: {
nativeWindowOpen: true,
preload: path.join(__dirname, 'preload.js'),
},
});
setTimeout(() => tracker.track(window), 1000);
if (tracker.state.isMaximized) window.maximize();
window.removeMenu();
window.maximize();
noScripts = killall ? { query: { noScripts: killall } } : {};
window.loadFile("index.html", noScripts);
window.show();

@ -1,12 +1,19 @@
/* eslint-disable no-process-exit */
/* eslint-disable @typescript-eslint/no-var-requires */
const { app, dialog, BrowserWindow } = require("electron");
const { app, dialog, BrowserWindow, ipcMain } = require("electron");
const log = require("electron-log");
const greenworks = require("./greenworks");
const api = require("./api-server");
const gameWindow = require("./gameWindow");
const achievements = require("./achievements");
const utils = require("./utils");
const storage = require("./storage");
const debounce = require("lodash/debounce");
const Config = require("electron-config");
const config = new Config();
log.transports.file.level = config.get("file-log-level", "info");
log.transports.console.level = config.get("console-log-level", "debug");
log.catchErrors();
log.info(`Started app: ${JSON.stringify(process.argv)}`);
@ -30,6 +37,8 @@ try {
global.greenworksError = ex.message;
}
let isRestoreDisabled = false;
function setStopProcessHandler(app, window, enabled) {
const closingWindowHandler = async (e) => {
// We need to prevent the default closing event to add custom logic
@ -41,6 +50,18 @@ function setStopProcessHandler(app, window, enabled) {
// Shutdown the http server
api.disable();
// Trigger debounced saves right now before closing
try {
await saveToDisk.flush();
} catch (error) {
log.error(error);
}
try {
await saveToCloud.flush();
} catch (error) {
log.error(error);
}
// Because of a steam limitation, if the player has launched an external browser,
// steam will keep displaying the game as "Running" in their UI as long as the browser is up.
// So we'll alert the player to close their browser.
@ -87,21 +108,106 @@ function setStopProcessHandler(app, window, enabled) {
process.exit(0);
};
const receivedGameReadyHandler = async (event, arg) => {
if (!window) {
log.warn("Window was undefined in game info handler");
return;
}
log.debug("Received game information", arg);
window.gameInfo = { ...arg };
await storage.prepareSaveFolders(window);
const restoreNewest = config.get("onload-restore-newest", true);
if (restoreNewest && !isRestoreDisabled) {
try {
await storage.restoreIfNewerExists(window)
} catch (error) {
log.error("Could not restore newer file", error);
}
}
}
const receivedDisableRestoreHandler = async (event, arg) => {
if (!window) {
log.warn("Window was undefined in disable import handler");
return;
}
log.debug(`Disabling auto-restore for ${arg.duration}ms.`);
isRestoreDisabled = true;
setTimeout(() => {
isRestoreDisabled = false;
log.debug("Re-enabling auto-restore");
}, arg.duration);
}
const receivedGameSavedHandler = async (event, arg) => {
if (!window) {
log.warn("Window was undefined in game saved handler");
return;
}
const { save, ...other } = arg;
log.silly("Received game saved info", {...other, save: `${save.length} bytes`});
if (storage.isAutosaveEnabled()) {
saveToDisk(save, arg.fileName);
}
if (storage.isCloudEnabled()) {
const minimumPlaytime = 1000 * 60 * 15;
const playtime = window.gameInfo.player.playtime;
log.silly(window.gameInfo);
if (playtime > minimumPlaytime) {
saveToCloud(save);
} else {
log.debug(`Auto-save to cloud disabled for save game under ${minimumPlaytime}ms (${playtime}ms)`);
}
}
}
const saveToCloud = debounce(async (save) => {
log.debug("Saving to Steam Cloud ...")
try {
const playerId = window.gameInfo.player.identifier;
await storage.pushGameSaveToSteamCloud(save, playerId);
log.silly("Saved Game to Steam Cloud");
} catch (error) {
log.error(error);
utils.writeToast(window, "Could not save to Steam Cloud.", "error", 5000);
}
}, config.get("cloud-save-min-time", 1000 * 60 * 15), { leading: true });
const saveToDisk = debounce(async (save, fileName) => {
log.debug("Saving to Disk ...")
try {
const file = await storage.saveGameToDisk(window, { save, fileName });
log.silly(`Saved Game to '${file.replaceAll('\\', '\\\\')}'`);
} catch (error) {
log.error(error);
utils.writeToast(window, "Could not save to disk", "error", 5000);
}
}, config.get("disk-save-min-time", 1000 * 60 * 5), { leading: true });
if (enabled) {
log.debug('Adding closing handlers');
log.debug("Adding closing handlers");
ipcMain.on("push-game-ready", receivedGameReadyHandler);
ipcMain.on("push-game-saved", receivedGameSavedHandler);
ipcMain.on("push-disable-restore", receivedDisableRestoreHandler)
window.on("closed", clearWindowHandler);
window.on("close", closingWindowHandler)
app.on("window-all-closed", stopProcessHandler);
} else {
log.debug('Removing closing handlers');
log.debug("Removing closing handlers");
ipcMain.removeAllListeners();
window.removeListener("closed", clearWindowHandler);
window.removeListener("close", closingWindowHandler);
app.removeListener("window-all-closed", stopProcessHandler);
}
}
function startWindow(noScript) {
gameWindow.createWindow(noScript);
async function startWindow(noScript) {
return gameWindow.createWindow(noScript);
}
global.app_handlers = {
@ -110,7 +216,7 @@ global.app_handlers = {
}
app.whenReady().then(async () => {
log.info('Application is ready!');
log.info("Application is ready!");
if (process.argv.includes("--export-save")) {
const window = new BrowserWindow({ show: false });
@ -119,15 +225,14 @@ app.whenReady().then(async () => {
setStopProcessHandler(app, window, true);
await utils.exportSave(window);
} else {
startWindow(process.argv.includes("--no-scripts"));
}
const window = await startWindow(process.argv.includes("--no-scripts"));
if (global.greenworksError) {
dialog.showMessageBox({
title: 'Bitburner',
message: 'Could not connect to Steam',
await dialog.showMessageBox(window, {
title: "Bitburner",
message: "Could not connect to Steam",
detail: `${global.greenworksError}\n\nYou won't be able to receive achievements until this is resolved and you restart the game.`,
type: 'warning', buttons: ['OK']
});
}
}
});

@ -1,11 +1,169 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { Menu, clipboard, dialog } = require("electron");
const { app, Menu, clipboard, dialog, shell } = require("electron");
const log = require("electron-log");
const Config = require("electron-config");
const api = require("./api-server");
const utils = require("./utils");
const storage = require("./storage");
const config = new Config();
function getMenu(window) {
return Menu.buildFromTemplate([
{
label: "File",
submenu: [
{
label: "Save Game",
click: () => window.webContents.send("trigger-save"),
},
{
label: "Export Save",
click: () => window.webContents.send("trigger-game-export"),
},
{
label: "Export Scripts",
click: async () => window.webContents.send("trigger-scripts-export"),
},
{
type: "separator",
},
{
label: "Load Last Save",
click: async () => {
try {
const saveGame = await storage.loadLastFromDisk(window);
window.webContents.send("push-save-request", { save: saveGame });
} catch (error) {
log.error(error);
utils.writeToast(window, "Could not load last save from disk", "error", 5000);
}
}
},
{
label: "Load From File",
click: async () => {
const defaultPath = await storage.getSaveFolder(window);
const result = await dialog.showOpenDialog(window, {
title: "Load From File",
defaultPath: defaultPath,
buttonLabel: "Load",
filters: [
{ name: "Game Saves", extensions: ["json", "json.gz", "txt"] },
{ name: "All", extensions: ["*"] },
],
properties: [
"openFile", "dontAddToRecent",
]
});
if (result.canceled) return;
const file = result.filePaths[0];
try {
const saveGame = await storage.loadFileFromDisk(file);
window.webContents.send("push-save-request", { save: saveGame });
} catch (error) {
log.error(error);
utils.writeToast(window, "Could not load save from disk", "error", 5000);
}
}
},
{
label: "Load From Steam Cloud",
enabled: storage.isCloudEnabled(),
click: async () => {
try {
const saveGame = await storage.getSteamCloudSaveString();
await storage.pushSaveGameForImport(window, saveGame, false);
} catch (error) {
log.error(error);
utils.writeToast(window, "Could not load from Steam Cloud", "error", 5000);
}
}
},
{
type: "separator",
},
{
label: "Compress Disk Saves (.gz)",
type: "checkbox",
checked: storage.isSaveCompressionEnabled(),
click: (menuItem) => {
storage.setSaveCompressionConfig(menuItem.checked);
utils.writeToast(window,
`${menuItem.checked ? "Enabled" : "Disabled"} Save Compression`, "info", 5000);
refreshMenu(window);
},
},
{
label: "Auto-Save to Disk",
type: "checkbox",
checked: storage.isAutosaveEnabled(),
click: (menuItem) => {
storage.setAutosaveConfig(menuItem.checked);
utils.writeToast(window,
`${menuItem.checked ? "Enabled" : "Disabled"} Auto-Save to Disk`, "info", 5000);
refreshMenu(window);
},
},
{
label: "Auto-Save to Steam Cloud",
type: "checkbox",
enabled: !global.greenworksError,
checked: storage.isCloudEnabled(),
click: (menuItem) => {
storage.setCloudEnabledConfig(menuItem.checked);
utils.writeToast(window,
`${menuItem.checked ? "Enabled" : "Disabled"} Auto-Save to Steam Cloud`, "info", 5000);
refreshMenu(window);
},
},
{
label: "Restore Newest on Load",
type: "checkbox",
checked: config.get("onload-restore-newest", true),
click: (menuItem) => {
config.set("onload-restore-newest", menuItem.checked);
utils.writeToast(window,
`${menuItem.checked ? "Enabled" : "Disabled"} Restore Newest on Load`, "info", 5000);
refreshMenu(window);
},
},
{
type: "separator",
},
{
label: "Open Directory",
submenu: [
{
label: "Open Game Directory",
click: () => shell.openPath(app.getAppPath()),
},
{
label: "Open Saves Directory",
click: async () => {
const path = await storage.getSaveFolder(window);
shell.openPath(path);
},
},
{
label: "Open Logs Directory",
click: () => shell.openPath(app.getPath("logs")),
},
{
label: "Open Data Directory",
click: () => shell.openPath(app.getPath("userData")),
},
]
},
{
type: "separator",
},
{
label: "Quit",
click: () => app.quit(),
},
]
},
{
label: "Edit",
submenu: [
@ -163,6 +321,17 @@ function getMenu(window) {
label: "Activate",
click: () => window.webContents.openDevTools(),
},
{
label: "Delete Steam Cloud Data",
enabled: !global.greenworksError,
click: async () => {
try {
await storage.deleteCloudFile();
} catch (error) {
log.error(error);
}
}
}
],
},
]);

@ -9,7 +9,8 @@
"version": "1.0.0",
"dependencies": {
"electron-config": "^2.0.0",
"electron-log": "^4.4.4"
"electron-log": "^4.4.4",
"lodash": "^4.17.21"
}
},
"node_modules/conf": {
@ -104,6 +105,11 @@
"node": ">=4"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/make-dir": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz",
@ -259,6 +265,11 @@
"path-exists": "^3.0.0"
}
},
"lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"make-dir": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz",

@ -16,6 +16,8 @@
"./dist/**/*",
"./node_modules/**/*",
"./public/**/*",
"./src/**",
"./lib/**,",
"*.js"
],
"directories": {
@ -23,6 +25,7 @@
},
"dependencies": {
"electron-config": "^2.0.0",
"electron-log": "^4.4.4"
"electron-log": "^4.4.4",
"lodash": "^4.17.21"
}
}

38
electron/preload.js Normal file

@ -0,0 +1,38 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { ipcRenderer, contextBridge } = require('electron')
const log = require("electron-log");
contextBridge.exposeInMainWorld(
"electronBridge", {
send: (channel, data) => {
log.log("Send on channel " + channel)
// whitelist channels
let validChannels = [
"get-save-data-response",
"get-save-info-response",
"push-game-saved",
"push-game-ready",
"push-import-result",
"push-disable-restore",
];
if (validChannels.includes(channel)) {
ipcRenderer.send(channel, data);
}
},
receive: (channel, func) => {
log.log("Receive on channel " + channel)
let validChannels = [
"get-save-data-request",
"get-save-info-request",
"push-save-request",
"trigger-save",
"trigger-game-export",
"trigger-scripts-export",
];
if (validChannels.includes(channel)) {
// Deliberately strip event as it includes `sender`
ipcRenderer.on(channel, (event, ...args) => func(...args));
}
}
}
);

386
electron/storage.js Normal file

@ -0,0 +1,386 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { app, ipcMain } = require("electron");
const zlib = require("zlib");
const path = require("path");
const fs = require("fs/promises");
const { promisify } = require("util");
const gzip = promisify(zlib.gzip);
const gunzip = promisify(zlib.gunzip);
const greenworks = require("./greenworks");
const log = require("electron-log");
const flatten = require("lodash/flatten");
const Config = require("electron-config");
const config = new Config();
// https://stackoverflow.com/a/69418940
const dirSize = async (directory) => {
const files = await fs.readdir(directory);
const stats = files.map(file => fs.stat(path.join(directory, file)));
return (await Promise.all(stats)).reduce((accumulator, { size }) => accumulator + size, 0);
}
const getDirFileStats = async (directory) => {
const files = await fs.readdir(directory);
const stats = files.map((f) => {
const file = path.join(directory, f);
return fs.stat(file).then((stat) => ({ file, stat }));
});
const data = (await Promise.all(stats));
return data;
};
const getNewestFile = async (directory) => {
const data = await getDirFileStats(directory)
return data.sort((a, b) => b.stat.mtime.getTime() - a.stat.mtime.getTime())[0];
};
const getAllSaves = async (window) => {
const rootDirectory = await getSaveFolder(window, true);
const data = await fs.readdir(rootDirectory, { withFileTypes: true});
const savesPromises = data.filter((e) => e.isDirectory()).
map((dir) => path.join(rootDirectory, dir.name)).
map((dir) => getDirFileStats(dir));
const saves = await Promise.all(savesPromises);
const flat = flatten(saves);
return flat;
}
async function prepareSaveFolders(window) {
const rootFolder = await getSaveFolder(window, true);
const currentFolder = await getSaveFolder(window);
const backupsFolder = path.join(rootFolder, "/_backups")
await prepareFolders(rootFolder, currentFolder, backupsFolder);
}
async function prepareFolders(...folders) {
for (const folder of folders) {
try {
// Making sure the folder exists
// eslint-disable-next-line no-await-in-loop
await fs.stat(folder);
} catch (error) {
if (error.code === 'ENOENT') {
log.warn(`'${folder}' not found, creating it...`);
// eslint-disable-next-line no-await-in-loop
await fs.mkdir(folder);
} else {
log.error(error);
}
}
}
}
async function getFolderSizeInBytes(saveFolder) {
try {
return await dirSize(saveFolder);
} catch (error) {
log.error(error);
}
}
function setAutosaveConfig(value) {
config.set("autosave-enabled", value);
}
function isAutosaveEnabled() {
return config.get("autosave-enabled", true);
}
function setSaveCompressionConfig(value) {
config.set("save-compression-enabled", value);
}
function isSaveCompressionEnabled() {
return config.get("save-compression-enabled", true);
}
function setCloudEnabledConfig(value) {
config.set("cloud-enabled", value);
}
async function getSaveFolder(window, root = false) {
if (root) return path.join(app.getPath("userData"), "/saves");
const identifier = window.gameInfo?.player?.identifier ?? "";
return path.join(app.getPath("userData"), "/saves", `/${identifier}`);
}
function isCloudEnabled() {
// If the Steam API could not be initialized on game start, we'll abort this.
if (global.greenworksError) return false;
// If the user disables it in Steam there's nothing we can do
if (!greenworks.isCloudEnabledForUser()) return false;
// Let's check the config file to see if it's been overriden
const enabledInConf = config.get("cloud-enabled", true);
if (!enabledInConf) return false;
const isAppEnabled = greenworks.isCloudEnabled();
if (!isAppEnabled) greenworks.enableCloud(true);
return true;
}
function saveCloudFile(name, content) {
return new Promise((resolve, reject) => {
greenworks.saveTextToFile(name, content, resolve, reject);
})
}
function getFirstCloudFile() {
const nbFiles = greenworks.getFileCount();
if (nbFiles === 0) throw new Error('No files in cloud');
const file = greenworks.getFileNameAndSize(0);
log.silly(`Found ${nbFiles} files.`)
log.silly(`First File: ${file.name} (${file.size} bytes)`);
return file.name;
}
function getCloudFile() {
const file = getFirstCloudFile();
return new Promise((resolve, reject) => {
greenworks.readTextFromFile(file, resolve, reject);
});
}
function deleteCloudFile() {
const file = getFirstCloudFile();
return new Promise((resolve, reject) => {
greenworks.deleteFile(file, resolve, reject);
});
}
async function getSteamCloudQuota() {
return new Promise((resolve, reject) => {
greenworks.getCloudQuota(resolve, reject)
});
}
async function backupSteamDataToDisk(currentPlayerId) {
const nbFiles = greenworks.getFileCount();
if (nbFiles === 0) return;
const file = greenworks.getFileNameAndSize(0);
const previousPlayerId = file.name.replace(".json.gz", "");
if (previousPlayerId !== currentPlayerId) {
const backupSave = await getSteamCloudSaveString();
const backupFile = path.join(app.getPath("userData"), "/saves/_backups", `${previousPlayerId}.json.gz`);
const buffer = Buffer.from(backupSave, 'base64').toString('utf8');
saveContent = await gzip(buffer);
await fs.writeFile(backupFile, saveContent, 'utf8');
log.debug(`Saved backup game to '${backupFile}`);
}
}
async function pushGameSaveToSteamCloud(base64save, currentPlayerId) {
if (!isCloudEnabled) return Promise.reject("Steam Cloud is not Enabled");
try {
backupSteamDataToDisk(currentPlayerId);
} catch (error) {
log.error(error);
}
const steamSaveName = `${currentPlayerId}.json.gz`;
// Let's decode the base64 string so GZIP is more efficient.
const buffer = Buffer.from(base64save, "base64");
const compressedBuffer = await gzip(buffer);
// We can't use utf8 for some reason, steamworks is unhappy.
const content = compressedBuffer.toString("base64");
log.debug(`Uncompressed: ${base64save.length} bytes`);
log.debug(`Compressed: ${content.length} bytes`);
log.debug(`Saving to Steam Cloud as ${steamSaveName}`);
try {
await saveCloudFile(steamSaveName, content);
} catch (error) {
log.error(error);
}
}
async function getSteamCloudSaveString() {
if (!isCloudEnabled()) return Promise.reject("Steam Cloud is not Enabled");
log.debug(`Fetching Save in Steam Cloud`);
const cloudString = await getCloudFile();
const gzippedBase64Buffer = Buffer.from(cloudString, "base64");
const uncompressedBuffer = await gunzip(gzippedBase64Buffer);
const content = uncompressedBuffer.toString("base64");
log.debug(`Compressed: ${cloudString.length} bytes`);
log.debug(`Uncompressed: ${content.length} bytes`);
return content;
}
async function saveGameToDisk(window, saveData) {
const currentFolder = await getSaveFolder(window);
let saveFolderSizeBytes = await getFolderSizeInBytes(currentFolder);
const maxFolderSizeBytes = config.get("autosave-quota", 1e8); // 100Mb per playerIndentifier
const remainingSpaceBytes = maxFolderSizeBytes - saveFolderSizeBytes;
log.debug(`Folder Usage: ${saveFolderSizeBytes} bytes`);
log.debug(`Folder Capacity: ${maxFolderSizeBytes} bytes`);
log.debug(`Remaining: ${remainingSpaceBytes} bytes (${(saveFolderSizeBytes / maxFolderSizeBytes * 100).toFixed(2)}% used)`)
const shouldCompress = isSaveCompressionEnabled();
const fileName = saveData.fileName;
const file = path.join(currentFolder, fileName + (shouldCompress ? ".gz" : ""));
try {
let saveContent = saveData.save;
if (shouldCompress) {
// Let's decode the base64 string so GZIP is more efficient.
const buffer = Buffer.from(saveContent, 'base64').toString('utf8');
saveContent = await gzip(buffer);
}
await fs.writeFile(file, saveContent, 'utf8');
log.debug(`Saved Game to '${file}'`);
log.debug(`Save Size: ${saveContent.length} bytes`);
} catch (error) {
log.error(error);
}
const fileStats = await getDirFileStats(currentFolder);
const oldestFiles = fileStats
.sort((a, b) => a.stat.mtime.getTime() - b.stat.mtime.getTime())
.map(f => f.file).filter(f => f !== file);
while (saveFolderSizeBytes > maxFolderSizeBytes && oldestFiles.length > 0) {
const fileToRemove = oldestFiles.shift();
log.debug(`Over Quota -> Removing "${fileToRemove}"`);
try {
// eslint-disable-next-line no-await-in-loop
await fs.unlink(fileToRemove);
} catch (error) {
log.error(error);
}
// eslint-disable-next-line no-await-in-loop
saveFolderSizeBytes = await getFolderSizeInBytes(currentFolder);
log.debug(`Save Folder: ${saveFolderSizeBytes} bytes`);
log.debug(`Remaining: ${maxFolderSizeBytes - saveFolderSizeBytes} bytes (${(saveFolderSizeBytes / maxFolderSizeBytes * 100).toFixed(2)}% used)`)
}
return file;
}
async function loadLastFromDisk(window) {
const folder = await getSaveFolder(window);
const last = await getNewestFile(folder);
log.debug(`Last modified file: "${last.file}" (${last.stat.mtime.toLocaleString()})`);
return loadFileFromDisk(last.file);
}
async function loadFileFromDisk(path) {
const buffer = await fs.readFile(path);
let content;
if (path.endsWith('.gz')) {
const uncompressedBuffer = await gunzip(buffer);
content = uncompressedBuffer.toString('base64');
log.debug(`Uncompressed file content (new size: ${content.length} bytes)`);
} else {
content = buffer.toString('utf8');
log.debug(`Loaded file with ${content.length} bytes`)
}
return content;
}
function getSaveInformation(window, save) {
return new Promise((resolve) => {
ipcMain.once("get-save-info-response", async (event, data) => {
resolve(data);
});
window.webContents.send("get-save-info-request", save);
});
}
function getCurrentSave(window) {
return new Promise((resolve) => {
ipcMain.once('get-save-data-response', (event, data) => {
resolve(data);
});
window.webContents.send('get-save-data-request');
});
}
function pushSaveGameForImport(window, save, automatic) {
ipcMain.once("push-import-result", async (event, arg) => {
log.debug(`Was save imported? ${arg.wasImported ? "Yes" : "No"}`);
});
window.webContents.send("push-save-request", { save, automatic });
}
async function restoreIfNewerExists(window) {
const currentSave = await getCurrentSave(window);
const currentData = await getSaveInformation(window, currentSave.save);
const steam = {};
const disk = {};
try {
steam.save = await getSteamCloudSaveString();
steam.data = await getSaveInformation(window, steam.save);
} catch (error) {
log.error("Could not retrieve steam file");
log.debug(error);
}
try {
const saves = (await getAllSaves()).
sort((a, b) => b.stat.mtime.getTime() - a.stat.mtime.getTime());
if (saves.length > 0) {
disk.save = await loadFileFromDisk(saves[0].file);
disk.data = await getSaveInformation(window, disk.save);
}
} catch(error) {
log.error("Could not retrieve disk file");
log.debug(error);
}
const lowPlaytime = 1000 * 60 * 15;
let bestMatch;
if (!steam.data && !disk.data) {
log.info("No data to import");
} else {
// We'll just compare using the lastSave field for now.
if (!steam.data) {
log.debug('Best potential save match: Disk');
bestMatch = disk;
} else if (!disk.data) {
log.debug('Best potential save match: Steam Cloud');
bestMatch = steam;
} else if ((steam.data.lastSave >= disk.data.lastSave)
|| (steam.data.playtime + lowPlaytime > disk.data.playtime)) {
// We want to prioritze steam data if the playtime is very close
log.debug('Best potential save match: Steam Cloud');
bestMatch = steam;
} else {
log.debug('Best potential save match: disk');
bestMatch = disk;
}
}
if (bestMatch) {
if (bestMatch.data.lastSave > currentData.lastSave + 5000) {
// We add a few seconds to the currentSave's lastSave to prioritize it
log.info("Found newer data than the current's save file");
log.silly(bestMatch.data);
await pushSaveGameForImport(window, bestMatch.save, true);
return true;
} else if(bestMatch.data.playtime > currentData.playtime && currentData.playtime < lowPlaytime) {
log.info("Found older save, but with more playtime, and current less than 15 mins played");
log.silly(bestMatch.data);
await pushSaveGameForImport(window, bestMatch.save, true);
return true;
} else {
log.debug("Current save data is the freshest");
return false;
}
}
}
module.exports = {
getCurrentSave, getSaveInformation,
restoreIfNewerExists, pushSaveGameForImport,
pushGameSaveToSteamCloud, getSteamCloudSaveString, getSteamCloudQuota, deleteCloudFile,
saveGameToDisk, loadLastFromDisk, loadFileFromDisk,
getSaveFolder, prepareSaveFolders, getAllSaves,
isCloudEnabled, setCloudEnabledConfig,
isAutosaveEnabled, setAutosaveConfig,
isSaveCompressionEnabled, setSaveCompressionConfig,
};

63
electron/windowTracker.js Normal file

@ -0,0 +1,63 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { screen } = require("electron");
const log = require("electron-log");
const debounce = require("lodash/debounce");
const Config = require("electron-config");
const config = new Config();
// https://stackoverflow.com/a/68627253
const windowTracker = (windowName) => {
let window, windowState;
const setBounds = () => {
// Restore from appConfig
if (config.has(`window.${windowName}`)) {
windowState = config.get(`window.${windowName}`);
return;
}
const size = screen.getPrimaryDisplay().workAreaSize;
// Default
windowState = {
x: undefined,
y: undefined,
width: size.width,
height: size.height,
isMaximized: true,
};
};
const saveState = debounce(() => {
if (!windowState.isMaximized) {
windowState = window.getBounds();
}
windowState.isMaximized = window.isMaximized();
log.silly(`Saving window.${windowName} to configs`);
config.set(`window.${windowName}`, windowState);
log.silly(windowState);
}, 1000);
const track = (win) => {
window = win;
['resize', 'move', 'close'].forEach((event) => {
win.on(event, saveState);
});
};
setBounds();
return {
state: {
x: windowState.x,
y: windowState.y,
width: windowState.width,
height: windowState.height,
isMaximized: windowState.isMaximized,
},
track,
};
};
module.exports = { windowTracker };

@ -73,5 +73,5 @@
<link rel="shortcut icon" href="favicon.ico"></head>
<body>
<div id="root"/>
<script type="text/javascript" src="dist/vendor.bundle.js"></script><script type="text/javascript" src="main.bundle.js"></script></body>
<script type="text/javascript" src="dist/vendor.bundle.js"></script><script type="text/javascript" src="dist/main.bundle.js"></script></body>
</html>

File diff suppressed because it is too large Load Diff

@ -8,4 +8,8 @@ module.exports = {
'.cypress', 'node_modules', 'dist',
],
testEnvironment: "jsdom",
moduleNameMapper: {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/test/__mocks__/fileMock.js",
"\\.(css|less)$": "<rootDir>/test/__mocks__/styleMock.js"
}
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1912
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -13,19 +13,10 @@
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"@material-ui/core": "^4.12.3",
"@microsoft/api-documenter": "^7.13.65",
"@microsoft/api-extractor": "^7.18.17",
"@monaco-editor/react": "^4.2.2",
"@mui/icons-material": "^5.0.3",
"@mui/material": "^5.0.3",
"@mui/styles": "^5.0.1",
"@types/acorn": "^4.0.6",
"@types/escodegen": "^0.0.7",
"@types/numeral": "0.0.25",
"@types/react": "^17.0.21",
"@types/react-beautiful-dnd": "^13.1.2",
"@types/react-dom": "^17.0.9",
"@types/react-resizable": "^1.7.3",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^5.0.0",
@ -45,7 +36,6 @@
"notistack": "^2.0.2",
"numeral": "2.0.6",
"prop-types": "^15.8.0",
"raw-loader": "^4.0.2",
"react": "^17.0.2",
"react-beautiful-dnd": "^13.1.0",
"react-dom": "^17.0.2",
@ -59,10 +49,19 @@
"@babel/preset-env": "^7.15.0",
"@babel/preset-react": "^7.0.0",
"@babel/preset-typescript": "^7.15.0",
"@microsoft/api-documenter": "^7.13.65",
"@microsoft/api-extractor": "^7.18.17",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.1",
"@testing-library/cypress": "^8.0.1",
"@types/acorn": "^4.0.6",
"@types/escodegen": "^0.0.7",
"@types/file-saver": "^2.0.3",
"@types/lodash": "^4.14.168",
"@types/numeral": "0.0.25",
"@types/react": "^17.0.21",
"@types/react-beautiful-dnd": "^13.1.2",
"@types/react-dom": "^17.0.9",
"@types/react-resizable": "^1.7.3",
"@typescript-eslint/eslint-plugin": "^4.22.0",
"@typescript-eslint/parser": "^4.22.0",
"babel-jest": "^27.0.6",
@ -71,6 +70,7 @@
"electron": "^14.0.2",
"electron-packager": "^15.4.0",
"eslint": "^7.24.0",
"file-loader": "^6.2.0",
"fork-ts-checker-webpack-plugin": "^6.3.3",
"html-webpack-plugin": "^3.2.0",
"http-server": "^13.0.1",
@ -79,6 +79,7 @@
"lodash": "^4.17.21",
"mini-css-extract-plugin": "^0.4.1",
"prettier": "^2.3.2",
"raw-loader": "^4.0.2",
"react-refresh": "^0.10.0",
"regenerator-runtime": "^0.13.9",
"source-map": "^0.7.3",
@ -102,7 +103,7 @@
"cy:dev": "start-server-and-test start:dev http://localhost:8000 cy:open",
"cy:open": "cypress open",
"cy:run": "cypress run",
"doc": "npx api-extractor run && npx api-documenter markdown",
"doc": "npx api-extractor run && npx api-documenter markdown && rm input/bitburner.api.json && rm -r input",
"format": "prettier --write .",
"start": "http-server -p 8000",
"start:dev": "webpack-dev-server --progress --env.devServer --mode development",
@ -112,13 +113,17 @@
"build:dev": "webpack --mode development",
"lint": "eslint --fix . --ext js,jsx,ts,tsx",
"lint:report": "eslint --ext js,jsx,ts,tsx .",
"preinstall": "node ./scripts/engines-check.js",
"preinstall": "node ./tools/engines-check/engines-check.js",
"test": "jest",
"test:watch": "jest --watch",
"watch": "webpack --watch --mode production",
"watch:dev": "webpack --watch --mode development",
"electron": "sh ./package.sh",
"electron:packager": "electron-packager .package bitburner --all --out .build --overwrite --icon .package/icon.png --no-prune",
"electron:packager-all": "electron-packager .package bitburner --all --out .build --overwrite --icon .package/icon.png",
"electron:packager-win": "electron-packager .package bitburner --platform win32 --arch x64 --out .build --overwrite --icon .package/icon.png",
"electron:packager-mac": "electron-packager .package bitburner --platform darwin --arch x64 --out .build --overwrite --icon .package/icon.png",
"electron:packager-linux": "electron-packager .package bitburner --platform linux --arch x64 --out .build --overwrite --icon .package/icon.png",
"allbuild": "npm run build && npm run electron && git add --all && git commit --amend --no-edit && git push -f -u origin dev"
}
}

@ -1,30 +1,18 @@
#!/bin/sh
mkdir -p .package/dist/src/ThirdParty || true
mkdir -p .package/src/ThirdParty || true
mkdir -p .package/node_modules || true
# Clear out any files remaining from old builds
rm -rf .package
cp index.html .package
mkdir -p .package/dist/
cp -r electron/* .package
cp -r dist/ext .package/dist
cp -r dist/icons .package/dist
# The css files
cp dist/vendor.css .package/dist
cp main.css .package/main.css
# The js files.
cp dist/vendor.bundle.js .package/dist/vendor.bundle.js
cp main.bundle.js .package/main.bundle.js
# Source maps
cp dist/vendor.bundle.js.map .package/dist/vendor.bundle.js.map
cp main.bundle.js.map .package/main.bundle.js.map
cp -r dist .package
cp index.html .package/index.html
# Install electron sub-dependencies
cd electron
npm install
cd ..
BUILD_PLATFORM="${1:-"all"}"
# And finally build the app.
npm run electron:packager
npm run electron:packager-$BUILD_PLATFORM

@ -1,2 +1,8 @@
// Defined by webpack on startup or compilation
declare let __COMMIT_HASH__: string;
// When using file-loader, we'll get a path to the resource
declare module "*.png" {
const value: string;
export default value;
}

@ -2050,7 +2050,7 @@ function initAugmentations(): void {
info:
"A brain implant carefully assembled around the synapses, which " +
"micromanages the activity and levels of various neuroreceptor " +
"chemicals and modulates electrical acvitiy to optimize concentration, " +
"chemicals and modulates electrical activity to optimize concentration, " +
"allowing the user to multitask much more effectively.",
stats: (
<>

@ -26,7 +26,7 @@ export function InstalledAugmentations(): React.ReactElement {
if (Settings.OwnedAugmentationsOrder === OwnedAugmentationsOrderSetting.Alphabetically) {
sourceAugs.sort((aug1, aug2) => {
return aug1.name <= aug2.name ? -1 : 1;
return aug1.name.localeCompare(aug2.name);
});
}

@ -85,7 +85,7 @@ function BitNodePortal(props: IPortalProps): React.ReactElement {
</Typography>
}
>
<span onClick={() => setPortalOpen(true)} className={cssClass}>
<span onClick={() => setPortalOpen(true)} className={cssClass} aria-label={`enter-bitnode-${bitNode.number.toString()}`}>
<b>O</b>
</span>
</Tooltip>

@ -14,6 +14,7 @@ import Typography from "@mui/material/Typography";
import TextField from "@mui/material/TextField";
const MAX_BET = 100e6;
export const DECK_COUNT = 5; // 5-deck multideck
enum Result {
Pending = "",
@ -45,7 +46,7 @@ export class Blackjack extends Game<Props, State> {
constructor(props: Props) {
super(props);
this.deck = new Deck(5); // 5-deck multideck
this.deck = new Deck(DECK_COUNT);
const initialBet = 1e6;

@ -40,13 +40,13 @@ const strategies: {
} = {
Red: {
match: (n: number): boolean => {
if (n === 0) return false;
return redNumbers.includes(n);
},
payout: 1,
},
Black: {
match: (n: number): boolean => {
if (n === 0) return false;
return !redNumbers.includes(n);
},
payout: 1,
@ -118,12 +118,6 @@ export function Roulette(props: IProps): React.ReactElement {
const [status, setStatus] = useState<string | JSX.Element>("waiting");
const [n, setN] = useState(0);
const [lock, setLock] = useState(true);
const [strategy, setStrategy] = useState<Strategy>({
payout: 0,
match: (): boolean => {
return false;
},
});
useEffect(() => {
const i = window.setInterval(step, 50);
@ -156,13 +150,12 @@ export function Roulette(props: IProps): React.ReactElement {
return `${n}${color}`;
}
function play(s: Strategy): void {
function play(strategy: Strategy): void {
if (reachedLimit(props.p)) return;
setCanPlay(false);
setLock(false);
setStatus("playing");
setStrategy(s);
setTimeout(() => {
let n = Math.floor(rng.random() * 37);

@ -159,18 +159,18 @@ export function SlotMachine(props: IProps): React.ReactElement {
const copy = index.slice();
for (let i = 0; i < copy.length; i++) {
if (copy[i] === locks[i] && !stoppedOne) continue;
copy[i] = (copy[i] + 1) % symbols.length;
copy[i] = (copy[i] - 1 >= 0) ? copy[i] - 1 : symbols.length - 1;
stoppedOne = true;
}
setIndex(copy);
if (stoppedOne && copy.every((e, i) => e === locks[i])) {
checkWinnings();
checkWinnings(getTable(copy, symbols));
}
}
function getTable(): string[][] {
function getTable(index:number[], symbols:string[]): string[][] {
return [
[
symbols[(index[0] + symbols.length - 1) % symbols.length],
@ -209,8 +209,7 @@ export function SlotMachine(props: IProps): React.ReactElement {
]);
}
function checkWinnings(): void {
const t = getTable();
function checkWinnings(t:string[][]): void {
const getPaylineData = function (payline: number[][]): string[] {
const data = [];
for (const point of payline) {
@ -267,7 +266,7 @@ export function SlotMachine(props: IProps): React.ReactElement {
setInvestment(investment);
}
const t = getTable();
const t = getTable(index, symbols);
// prettier-ignore
return (
<>

@ -26,6 +26,7 @@ import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableRow from "@mui/material/TableRow";
import { TableCell } from "../../ui/React/Table";
import { Box } from "@mui/material";
interface IProps {
office: OfficeSpace;
@ -430,20 +431,17 @@ export function IndustryOffice(props: IProps): React.ReactElement {
<Typography>
Size: {props.office.employees.length} / {props.office.size} employees
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr', width: 'fit-content' }}>
<Box sx={{ gridTemplateColumns: 'repeat(3, 1fr)' }}>
<Tooltip title={<Typography>Automatically hires an employee and gives him/her a random name</Typography>}>
<span>
<Button disabled={props.office.atCapacity()} onClick={autohireEmployeeButtonOnClick}>
Hire Employee
</Button>
</span>
</Tooltip>
<br />
<Tooltip title={<Typography>Upgrade the office's size so that it can hold more employees!</Typography>}>
<span>
<Button disabled={corp.funds < 0} onClick={() => setUpgradeOfficeSizeOpen(true)}>
Upgrade size
</Button>
</span>
</Tooltip>
<UpgradeOfficeSizeModal
rerender={props.rerender}
@ -457,11 +455,9 @@ export function IndustryOffice(props: IProps): React.ReactElement {
<Tooltip
title={<Typography>Throw an office party to increase your employee's morale and happiness</Typography>}
>
<span>
<Button disabled={corp.funds < 0} onClick={() => setThrowPartyOpen(true)}>
Throw Party
</Button>
</span>
</Tooltip>
<ThrowPartyModal
rerender={props.rerender}
@ -472,9 +468,9 @@ export function IndustryOffice(props: IProps): React.ReactElement {
</>
)}
<br />
</Box>
<SwitchButton manualMode={employeeManualAssignMode} switchMode={setEmployeeManualAssignMode} />
</Box>
{employeeManualAssignMode ? (
<ManualManagement rerender={props.rerender} office={props.office} />
) : (

@ -139,12 +139,12 @@ function WarehouseRoot(props: IProps): React.ReactElement {
{numeralWrapper.formatBigNumber(props.warehouse.size)}
</Typography>
</Tooltip>
</Box>
<Button disabled={!canAffordUpgrade} onClick={upgradeWarehouseOnClick}>
Upgrade Warehouse Size -&nbsp;
<MoneyCost money={sizeUpgradeCost} corp={corp} />
</Button>
</Box>
<Typography>This industry uses the following equation for its production: </Typography>
<br />

@ -112,7 +112,7 @@ export function MaterialElem(props: IMaterialProps): React.ReactElement {
return (
<Paper>
<Box display="flex">
<Box sx={{ display: 'grid', gridTemplateColumns: '2fr 1fr', m: '5px' }}>
<Box>
<Tooltip
title={
@ -149,11 +149,10 @@ export function MaterialElem(props: IMaterialProps): React.ReactElement {
</Tooltip>
</Box>
<Box>
<Box sx={{ "& button": { width: '100%' } }}>
<Tooltip
title={tutorial ? <Typography>Purchase your required materials to get production started!</Typography> : ""}
>
<span>
<Button
color={tutorial ? "error" : "primary"}
onClick={() => setPurchaseMaterialOpen(true)}
@ -161,7 +160,6 @@ export function MaterialElem(props: IMaterialProps): React.ReactElement {
>
{purchaseButtonText}
</Button>
</span>
</Tooltip>
<PurchaseMaterialModal
mat={mat}
@ -177,7 +175,6 @@ export function MaterialElem(props: IMaterialProps): React.ReactElement {
<ExportModal mat={mat} open={exportOpen} onClose={() => setExportOpen(false)} />
</>
)}
<br />
<Button
color={division.prodMats.includes(props.mat.name) && !mat.sllman[0] ? "error" : "primary"}

@ -89,6 +89,7 @@ export function Overview({ rerender }: IProps): React.ReactElement {
<StatsTable rows={multRows} />
<br />
<BonusTime />
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', width: 'fit-content' }}>
<Tooltip
title={
<Typography>
@ -102,6 +103,7 @@ export function Overview({ rerender }: IProps): React.ReactElement {
</Tooltip>
{corp.public ? <PublicButtons rerender={rerender} /> : <PrivateButtons rerender={rerender} />}
<BribeButton />
</Box>
<br />
<Upgrades rerender={rerender} />
</>
@ -125,11 +127,9 @@ function PrivateButtons({ rerender }: IPrivateButtonsProps): React.ReactElement
return (
<>
<Tooltip title={<Typography>{findInvestorsTooltip}</Typography>}>
<span>
<Button disabled={!fundingAvailable} onClick={() => setFindInvestorsopen(true)}>
Find Investors
</Button>
</span>
</Tooltip>
<Tooltip
title={
@ -143,7 +143,6 @@ function PrivateButtons({ rerender }: IPrivateButtonsProps): React.ReactElement
</Tooltip>
<FindInvestorsModal open={findInvestorsopen} onClose={() => setFindInvestorsopen(false)} rerender={rerender} />
<GoPublicModal open={goPublicopen} onClose={() => setGoPublicopen(false)} rerender={rerender} />
<br />
</>
);
}
@ -212,28 +211,21 @@ function PublicButtons({ rerender }: IPublicButtonsProps): React.ReactElement {
return (
<>
<Tooltip title={<Typography>{sellSharesTooltip}</Typography>}>
<span>
<Button disabled={sellSharesOnCd} onClick={() => setSellSharesOpen(true)}>
Sell Shares
</Button>
</span>
</Tooltip>
<SellSharesModal open={sellSharesOpen} onClose={() => setSellSharesOpen(false)} rerender={rerender} />
<Tooltip title={<Typography>Buy back shares you that previously issued or sold at market price.</Typography>}>
<span>
<Button disabled={corp.issuedShares < 1} onClick={() => setBuybackSharesOpen(true)}>
Buyback shares
</Button>
</span>
</Tooltip>
<BuybackSharesModal open={buybackSharesOpen} onClose={() => setBuybackSharesOpen(false)} rerender={rerender} />
<br />
<Tooltip title={<Typography>{issueNewSharesTooltip}</Typography>}>
<span>
<Button disabled={issueNewSharesOnCd} onClick={() => setIssueNewSharesOpen(true)}>
Issue New Shares
</Button>
</span>
</Tooltip>
<IssueNewSharesModal open={issueNewSharesOpen} onClose={() => setIssueNewSharesOpen(false)} />
<Tooltip
@ -242,7 +234,6 @@ function PublicButtons({ rerender }: IPublicButtonsProps): React.ReactElement {
<Button onClick={() => setIssueDividendsOpen(true)}>Issue Dividends</Button>
</Tooltip>
<IssueDividendsModal open={issueDividendsOpen} onClose={() => setIssueDividendsOpen(false)} />
<br />
</>
);
}
@ -269,11 +260,9 @@ function BribeButton(): React.ReactElement {
: "Your Corporation is not powerful enough to bribe Faction leaders"
}
>
<span>
<Button disabled={!canBribe} onClick={openBribe}>
Bribe Factions
</Button>
</span>
</Tooltip>
<BribeFactionModal open={open} onClose={() => setOpen(false)} />
</>

@ -81,7 +81,7 @@ export function ProductElem(props: IProductProps): React.ReactElement {
);
} else if (product.sCost) {
if (isString(product.sCost)) {
const sCost = (product.sCost as string).replace(/MP/g, product.pCost + "");
const sCost = (product.sCost as string).replace(/MP/g, product.pCost + product.rat / product.mku + "");
sellButtonText = (
<>
{sellButtonText} @ <Money money={eval(sCost)} />

@ -6,17 +6,18 @@ import { IIndustry } from "../IIndustry";
import { Research } from "../Actions";
import { Node } from "../ResearchTree";
import { ResearchMap } from "../ResearchMap";
import { Settings } from "../../Settings/Settings";
import { dialogBoxCreate } from "../../ui/React/DialogBox";
import Typography from "@mui/material/Typography";
import Tooltip from "@mui/material/Tooltip";
import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemText from "@mui/material/ListItemText";
import Collapse from "@mui/material/Collapse";
import ExpandMore from "@mui/icons-material/ExpandMore";
import ExpandLess from "@mui/icons-material/ExpandLess";
import CheckIcon from '@mui/icons-material/Check';
interface INodeProps {
n: Node | null;
division: IIndustry;
@ -52,8 +53,8 @@ function Upgrade({ n, division }: INodeProps): React.ReactElement {
color = "info";
}
const but = (
<Box>
const wrapInTooltip = (ele: React.ReactElement): React.ReactElement => {
return (
<Tooltip
title={
<Typography>
@ -63,12 +64,22 @@ function Upgrade({ n, division }: INodeProps): React.ReactElement {
</Typography>
}
>
{ele}
</Tooltip>
)
}
const but = (
<Box>
{wrapInTooltip(
<span>
<Button color={color} disabled={disabled && !n.researched} onClick={research}>
{n.text}
<Button color={color} disabled={disabled && !n.researched} onClick={research}
style={{ width: '100%', textAlign: 'left', justifyContent: 'unset' }}
>
{n.researched && (<CheckIcon sx={{ mr: 1 }} />)}{n.text}
</Button>
</span>
</Tooltip>
)}
</Box>
);
@ -76,15 +87,25 @@ function Upgrade({ n, division }: INodeProps): React.ReactElement {
return (
<Box>
<Box display="flex">
{but}
<ListItemButton onClick={() => setOpen((old) => !old)}>
<ListItemText />
<Box display="flex" sx={{ border: '1px solid ' + Settings.theme.well }}>
{wrapInTooltip(
<span style={{ width: '100%' }}>
<Button color={color} disabled={disabled && !n.researched} onClick={research} sx={{
width: '100%',
textAlign: 'left',
justifyContent: 'unset',
borderColor: Settings.theme.button
}}>
{n.researched && (<CheckIcon sx={{ mr: 1 }} />)}{n.text}
</Button>
</span>
)}
<Button onClick={() => setOpen((old) => !old)} sx={{ borderColor: Settings.theme.button, minWidth: 'fit-content' }}>
{open ? <ExpandLess color="primary" /> : <ExpandMore color="primary" />}
</ListItemButton>
</Button>
</Box>
<Collapse in={open} unmountOnExit>
<Box m={4}>
<Box m={1}>
{n.children.map((m) => (
<Upgrade key={m.text} division={division} n={m} />
))}
@ -108,7 +129,7 @@ export function ResearchModal(props: IProps): React.ReactElement {
return (
<Modal open={props.open} onClose={props.onClose}>
<Upgrade division={props.industry} n={researchTree.root} />
<Typography>
<Typography sx={{ mt: 1 }}>
Research points: {props.industry.sciResearch.qty.toFixed(3)}
<br />
Multipliers from research:

@ -8,6 +8,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import { IPlayer } from "../../PersonObjects/IPlayer";
import { Adjuster } from "./Adjuster";
interface IProps {
player: IPlayer;
@ -38,6 +39,12 @@ export function Sleeves(props: IProps): React.ReactElement {
}
}
function sleeveSetStoredCycles(cycles: number): void {
for (let i = 0; i < props.player.sleeves.length; ++i) {
props.player.sleeves[i].storedCycles = cycles;
}
}
return (
<Accordion TransitionProps={{ unmountOnExit: true }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
@ -68,6 +75,18 @@ export function Sleeves(props: IProps): React.ReactElement {
<Button onClick={sleeveSyncClearAll}>Clear all</Button>
</td>
</tr>
<tr>
<td colSpan={3}>
<Adjuster
label="Stored Cycles"
placeholder="cycles"
tons={() => sleeveSetStoredCycles(10000000)}
add={sleeveSetStoredCycles}
subtract={sleeveSetStoredCycles}
reset={() => sleeveSetStoredCycles(0)}
/>
</td>
</tr>
</tbody>
</table>
</AccordionDetails>

@ -1,11 +1,18 @@
import { Player } from "./Player";
import { Router } from "./ui/GameRoot";
import { isScriptFilename } from "./Script/isScriptFilename";
import { Script } from "./Script/Script";
import { removeLeadingSlash } from "./Terminal/DirectoryHelpers";
import { Terminal } from "./Terminal";
import { SnackbarEvents } from "./ui/React/Snackbar";
import { IMap } from "./types";
import { IMap, IReturnStatus } from "./types";
import { GetServer } from "./Server/AllServers";
import { resolve } from "cypress/types/bluebird";
import { ImportPlayerData, SaveData, saveObject } from "./SaveObject";
import { Settings } from "./Settings/Settings";
import { exportScripts } from "./Terminal/commands/download";
import { CONSTANTS } from "./Constants";
import { hash } from "./hash/hash";
export function initElectron(): void {
const userAgent = navigator.userAgent.toLowerCase();
@ -14,36 +21,81 @@ export function initElectron(): void {
(document as any).achievements = [];
initWebserver();
initAppNotifier();
initSaveFunctions();
initElectronBridge();
}
}
function initWebserver(): void {
(document as any).saveFile = function (filename: string, code: string): string {
interface IReturnWebStatus extends IReturnStatus {
data?: {
[propName: string]: any;
};
}
function normalizeFileName(filename: string): string {
filename = filename.replace(/\/\/+/g, "/");
filename = removeLeadingSlash(filename);
if (filename.includes("/")) {
filename = "/" + removeLeadingSlash(filename);
}
return filename;
}
(document as any).getFiles = function (): IReturnWebStatus {
const home = GetServer("home");
if (home === null) {
return {
res: false,
msg: "Home server does not exist.",
};
}
return {
res: true,
data: {
files: home.scripts.map((script) => ({
filename: script.filename,
code: script.code,
ramUsage: script.ramUsage,
})),
},
};
};
(document as any).deleteFile = function (filename: string): IReturnWebStatus {
filename = normalizeFileName(filename);
const home = GetServer("home");
if (home === null) {
return {
res: false,
msg: "Home server does not exist.",
};
}
return home.removeFile(filename);
};
(document as any).saveFile = function (filename: string, code: string): IReturnWebStatus {
filename = normalizeFileName(filename);
code = Buffer.from(code, "base64").toString();
const home = GetServer("home");
if (home === null) return "'home' server not found.";
if (isScriptFilename(filename)) {
//If the current script already exists on the server, overwrite it
for (let i = 0; i < home.scripts.length; i++) {
if (filename == home.scripts[i].filename) {
home.scripts[i].saveScript(Player, filename, code, "home", home.scripts);
return "written";
if (home === null) {
return {
res: false,
msg: "Home server does not exist.",
};
}
const { success, overwritten } = home.writeToScriptFile(Player, filename, code);
let script;
if (success) {
script = home.getScript(filename);
}
//If the current script does NOT exist, create a new one
const script = new Script();
script.saveScript(Player, filename, code, "home", home.scripts);
home.scripts.push(script);
return "written";
}
return "not a script file";
return {
res: success,
data: {
overwritten,
ramUsage: script?.ramUsage,
},
};
};
}
@ -67,6 +119,123 @@ function initAppNotifier(): void {
};
// Will be consumud by the electron wrapper.
// @ts-ignore
window.appNotifier = funcs;
(window as any).appNotifier = funcs;
}
function initSaveFunctions(): void {
const funcs = {
triggerSave: (): Promise<void> => saveObject.saveGame(true),
triggerGameExport: (): void => {
try {
saveObject.exportGame();
} catch (error) {
console.log(error);
SnackbarEvents.emit("Could not export game.", "error", 2000);
}
},
triggerScriptsExport: (): void => exportScripts("*", Player.getHomeComputer()),
getSaveData: (): { save: string; fileName: string } => {
return {
save: saveObject.getSaveString(Settings.ExcludeRunningScriptsFromSave),
fileName: saveObject.getSaveFileName(),
};
},
getSaveInfo: async (base64save: string): Promise<ImportPlayerData | undefined> => {
try {
const data = await saveObject.getImportDataFromString(base64save);
return data.playerData;
} catch (error) {
console.error(error);
return;
}
},
pushSaveData: (base64save: string, automatic = false): void => Router.toImportSave(base64save, automatic),
};
// Will be consumud by the electron wrapper.
(window as any).appSaveFns = funcs;
}
function initElectronBridge(): void {
const bridge = (window as any).electronBridge as any;
if (!bridge) return;
bridge.receive("get-save-data-request", () => {
const data = (window as any).appSaveFns.getSaveData();
bridge.send("get-save-data-response", data);
});
bridge.receive("get-save-info-request", async (save: string) => {
const data = await (window as any).appSaveFns.getSaveInfo(save);
bridge.send("get-save-info-response", data);
});
bridge.receive("push-save-request", ({ save, automatic = false }: { save: string; automatic: boolean }) => {
(window as any).appSaveFns.pushSaveData(save, automatic);
});
bridge.receive("trigger-save", () => {
return (window as any).appSaveFns
.triggerSave()
.then(() => {
bridge.send("save-completed");
})
.catch((error: any) => {
console.log(error);
SnackbarEvents.emit("Could not save game.", "error", 2000);
});
});
bridge.receive("trigger-game-export", () => {
try {
(window as any).appSaveFns.triggerGameExport();
} catch (error) {
console.log(error);
SnackbarEvents.emit("Could not export game.", "error", 2000);
}
});
bridge.receive("trigger-scripts-export", () => {
try {
(window as any).appSaveFns.triggerScriptsExport();
} catch (error) {
console.log(error);
SnackbarEvents.emit("Could not export scripts.", "error", 2000);
}
});
}
export function pushGameSaved(data: SaveData): void {
const bridge = (window as any).electronBridge as any;
if (!bridge) return;
bridge.send("push-game-saved", data);
}
export function pushGameReady(): void {
const bridge = (window as any).electronBridge as any;
if (!bridge) return;
// Send basic information to the electron wrapper
bridge.send("push-game-ready", {
player: {
identifier: Player.identifier,
playtime: Player.totalPlaytime,
lastSave: Player.lastSave,
},
game: {
version: CONSTANTS.VersionString,
hash: hash(),
},
});
}
export function pushImportResult(wasImported: boolean): void {
const bridge = (window as any).electronBridge as any;
if (!bridge) return;
bridge.send("push-import-result", { wasImported });
pushDisableRestore();
}
export function pushDisableRestore(): void {
const bridge = (window as any).electronBridge as any;
if (!bridge) return;
bridge.send("push-disable-restore", { duration: 1000 * 60 });
}

@ -77,7 +77,7 @@ export function DonateOption(props: IProps): React.ReactElement {
}
return (
<Paper sx={{ my: 1, p: 1, width: "100%" }}>
<Paper sx={{ my: 1, p: 1 }}>
<Status />
{props.disabled ? (
<Typography>

@ -1,17 +1,17 @@
import React, { useState, useEffect } from "react";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Container from "@mui/material/Container";
import Paper from "@mui/material/Paper";
import TableBody from "@mui/material/TableBody";
import TableRow from "@mui/material/TableRow";
import Typography from "@mui/material/Typography";
import React, { useEffect, useState } from "react";
import { IPlayer } from "../../PersonObjects/IPlayer";
import { Table, TableCell } from "../../ui/React/Table";
import { IRouter } from "../../ui/Router";
import { Factions } from "../Factions";
import { Faction } from "../Faction";
import { joinFaction } from "../FactionHelpers";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import Link from "@mui/material/Link";
import Button from "@mui/material/Button";
import TableBody from "@mui/material/TableBody";
import { Table, TableCell } from "../../ui/React/Table";
import TableRow from "@mui/material/TableRow";
import { Factions } from "../Factions";
export const InvitationsSeen: string[] = [];
@ -48,42 +48,67 @@ export function FactionsRoot(props: IProps): React.ReactElement {
}
return (
<>
<Container disableGutters maxWidth="md" sx={{ mx: 0, mb: 10 }}>
<Typography variant="h4">Factions</Typography>
<Typography>Lists all factions you have joined</Typography>
<br />
<Box display="flex" flexDirection="column">
{props.player.factions.map((faction: string) => (
<Link key={faction} variant="h6" onClick={() => openFaction(Factions[faction])}>
{faction}
</Link>
))}
</Box>
<br />
{props.player.factionInvitations.length > 0 && (
<>
<Typography variant="h5" color="primary">
Outstanding Faction Invitations
<Typography mb={4}>
Throughout the game you may receive invitations from factions. There are many different factions, and each
faction has different criteria for determining its potential members. Joining a faction and furthering its cause
is crucial to progressing in the game and unlocking endgame content.
</Typography>
<Typography>
Lists factions you have been invited to. You can accept these faction invitations at any time.
<Typography variant="h5" color="primary" mt={2} mb={1}>
Factions you have joined:
</Typography>
<Table size="small" padding="none">
{(props.player.factions.length > 0 && (
<Paper sx={{ my: 1, p: 1, pb: 0, display: "inline-block" }}>
<Table padding="none">
<TableBody>
{props.player.factionInvitations.map((faction: string) => (
{props.player.factions.map((faction: string) => (
<TableRow key={faction}>
<TableCell>
<Typography noWrap>{faction}</Typography>
<Typography noWrap mb={1}>
{faction}
</Typography>
</TableCell>
<TableCell align="right">
<Button onClick={(e) => acceptInvitation(e, faction)}>Join!</Button>
<Box ml={1} mb={1}>
<Button onClick={() => openFaction(Factions[faction])}>Details</Button>
</Box>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
)}
</>
</Paper>
)) || <Typography>You haven't joined any factions.</Typography>}
<Typography variant="h5" color="primary" mt={4} mb={1}>
Outstanding Faction Invitations
</Typography>
<Typography mb={1}>
Factions you have been invited to. You can accept these faction invitations at any time:
</Typography>
{(props.player.factionInvitations.length > 0 && (
<Paper sx={{ my: 1, mb: 4, p: 1, pb: 0, display: "inline-block" }}>
<Table padding="none">
<TableBody>
{props.player.factionInvitations.map((faction: string) => (
<TableRow key={faction}>
<TableCell>
<Typography noWrap mb={1}>
{faction}
</Typography>
</TableCell>
<TableCell align="right">
<Box ml={1} mb={1}>
<Button onClick={(e) => acceptInvitation(e, faction)}>Join!</Button>
</Box>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
)) || <Typography>You have no outstanding faction invites.</Typography>}
</Container>
);
}

@ -19,7 +19,7 @@ type IProps = {
export function Option(props: IProps): React.ReactElement {
return (
<Box>
<Paper sx={{ my: 1, p: 1, width: "100%" }}>
<Paper sx={{ my: 1, p: 1 }}>
<Button onClick={props.onClick}>{props.buttonText}</Button>
<Typography>{props.infoText}</Typography>
</Paper>

@ -31,7 +31,7 @@ export function AscensionModal(props: IProps): React.ReactElement {
props.onAscend();
const res = gang.ascendMember(props.member);
dialogBoxCreate(
<Typography>
<>
You ascended {props.member.name}!<br />
<br />
Your gang lost {numeralWrapper.formatRespect(res.respect)} respect.
@ -51,7 +51,7 @@ export function AscensionModal(props: IProps): React.ReactElement {
<br />
Charisma: x{numeralWrapper.format(res.cha, "0.000")}
<br />
</Typography>,
</>
);
props.onClose();
}

@ -2,20 +2,27 @@
* React Component for the popup that manages gang members upgrades
*/
import React, { useState } from "react";
import { formatNumber } from "../../utils/StringHelperFunctions";
import { numeralWrapper } from "../../ui/numeralFormat";
import { GangMemberUpgrades } from "../GangMemberUpgrades";
import { GangMemberUpgrade } from "../GangMemberUpgrade";
import { Money } from "../../ui/React/Money";
import { useGang } from "./Context";
import { GangMember } from "../GangMember";
import { UpgradeType } from "../data/upgrades";
import { use } from "../../ui/Context";
import { generateTableRow } from "./GangMemberStats";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import Tooltip from "@mui/material/Tooltip";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import Select, { SelectChangeEvent } from "@mui/material/Select";
import { MenuItem, Table, TableBody, TextField } from "@mui/material";
import SearchIcon from "@mui/icons-material/Search";
import { numeralWrapper } from "../../ui/numeralFormat";
import { GangMemberUpgrades } from "../GangMemberUpgrades";
import { GangMemberUpgrade } from "../GangMemberUpgrade";
import { Money } from "../../ui/React/Money";
import { GangMember } from "../GangMember";
import { UpgradeType } from "../data/upgrades";
import { use } from "../../ui/Context";
import { Settings } from "../../Settings/Settings";
import { characterOverviewStyles as useStyles } from "../../ui/React/CharacterOverview";
interface INextRevealProps {
upgrades: string[];
@ -46,12 +53,10 @@ function NextReveal(props: INextRevealProps): React.ReactElement {
function PurchasedUpgrade({ upgName }: { upgName: string }): React.ReactElement {
const upg = GangMemberUpgrades[upgName];
return (
<Paper sx={{ mx: 1, p: 1 }}>
<Box display="flex">
<Paper sx={{ p: 1 }}>
<Tooltip title={<Typography dangerouslySetInnerHTML={{ __html: upg.desc }} />}>
<Typography>{upg.name}</Typography>
</Tooltip>
</Box>
</Paper>
);
}
@ -72,8 +77,8 @@ function UpgradeButton(props: IUpgradeButtonProps): React.ReactElement {
return (
<Tooltip title={<Typography dangerouslySetInnerHTML={{ __html: props.upg.desc }} />}>
<span>
<Typography>{props.upg.name}</Typography>
<Button onClick={onClick}>
<Button onClick={onClick} sx={{ display: 'flex', flexDirection: 'column', width: '100%', height: '100%' }}>
<Typography sx={{ display: 'block' }}>{props.upg.name}</Typography>
<Money money={gang.getUpgradeCost(props.upg)} />
</Button>
</span>
@ -86,12 +91,16 @@ interface IPanelProps {
}
function GangMemberUpgradePanel(props: IPanelProps): React.ReactElement {
const classes = useStyles();
const gang = useGang();
const player = use.Player();
const setRerender = useState(false)[1];
const [currentCategory, setCurrentCategory] = useState("Weapons");
function rerender(): void {
setRerender((old) => !old);
}
function filterUpgrades(list: string[], type: UpgradeType): GangMemberUpgrade[] {
return Object.keys(GangMemberUpgrades)
.filter((upgName: string) => {
@ -103,12 +112,26 @@ function GangMemberUpgradePanel(props: IPanelProps): React.ReactElement {
})
.map((upgName: string) => GangMemberUpgrades[upgName]);
}
const onChange = (event: SelectChangeEvent<string>): void => {
setCurrentCategory(event.target.value);
rerender()
}
const weaponUpgrades = filterUpgrades(props.member.upgrades, UpgradeType.Weapon);
const armorUpgrades = filterUpgrades(props.member.upgrades, UpgradeType.Armor);
const vehicleUpgrades = filterUpgrades(props.member.upgrades, UpgradeType.Vehicle);
const rootkitUpgrades = filterUpgrades(props.member.upgrades, UpgradeType.Rootkit);
const augUpgrades = filterUpgrades(props.member.augmentations, UpgradeType.Augmentation);
const categories: { [key: string]: (GangMemberUpgrade[] | UpgradeType)[] } = {
'Weapons': [weaponUpgrades, UpgradeType.Weapon],
'Armor': [armorUpgrades, UpgradeType.Armor],
'Vehicles': [vehicleUpgrades, UpgradeType.Vehicle],
'Rootkits': [rootkitUpgrades, UpgradeType.Rootkit],
'Augmentations': [augUpgrades, UpgradeType.Augmentation]
};
const asc = {
hack: props.member.calculateAscensionMult(props.member.hack_asc_points),
str: props.member.calculateAscensionMult(props.member.str_asc_points),
@ -119,26 +142,89 @@ function GangMemberUpgradePanel(props: IPanelProps): React.ReactElement {
};
return (
<Paper>
<Box display="grid" sx={{ gridTemplateColumns: '1fr 1fr', m: 1, gap: 1 }}>
<span>
<Typography variant="h5" color="primary">
{props.member.name} ({props.member.task})
</Typography>
<Tooltip
title={
<Typography>
Hack: {props.member.hack} (x
{formatNumber(props.member.hack_mult * asc.hack, 2)})<br />
Str: {props.member.str} (x
{formatNumber(props.member.str_mult * asc.str, 2)})<br />
Def: {props.member.def} (x
{formatNumber(props.member.def_mult * asc.def, 2)})<br />
Dex: {props.member.dex} (x
{formatNumber(props.member.dex_mult * asc.dex, 2)})<br />
Agi: {props.member.agi} (x
{formatNumber(props.member.agi_mult * asc.agi, 2)})<br />
Cha: {props.member.cha} (x
{formatNumber(props.member.cha_mult * asc.cha, 2)})
</Typography>
<Box display="flex" flexWrap="wrap">
<Typography>Purchased Upgrades: </Typography>
Hk: x{numeralWrapper.formatMultiplier(props.member.hack_mult * asc.hack)}(x
{numeralWrapper.formatMultiplier(props.member.hack_mult)} Eq, x{numeralWrapper.formatMultiplier(asc.hack)}{" "}
Asc)
<br />
St: x{numeralWrapper.formatMultiplier(props.member.str_mult * asc.str)}
(x{numeralWrapper.formatMultiplier(props.member.str_mult)} Eq, x{numeralWrapper.formatMultiplier(asc.str)}{" "}
Asc)
<br />
Df: x{numeralWrapper.formatMultiplier(props.member.def_mult * asc.def)}
(x{numeralWrapper.formatMultiplier(props.member.def_mult)} Eq, x{numeralWrapper.formatMultiplier(asc.def)}{" "}
Asc)
<br />
Dx: x{numeralWrapper.formatMultiplier(props.member.dex_mult * asc.dex)}
(x{numeralWrapper.formatMultiplier(props.member.dex_mult)} Eq, x{numeralWrapper.formatMultiplier(asc.dex)}{" "}
Asc)
<br />
Ag: x{numeralWrapper.formatMultiplier(props.member.agi_mult * asc.agi)}
(x{numeralWrapper.formatMultiplier(props.member.agi_mult)} Eq, x{numeralWrapper.formatMultiplier(asc.agi)}{" "}
Asc)
<br />
Ch: x{numeralWrapper.formatMultiplier(props.member.cha_mult * asc.cha)}
(x{numeralWrapper.formatMultiplier(props.member.cha_mult)} Eq, x{numeralWrapper.formatMultiplier(asc.cha)}{" "}
Asc)
</Typography>
}
>
<Table>
<TableBody>
{generateTableRow("Hacking", props.member.hack, props.member.hack_exp, Settings.theme.hack, classes)}
{generateTableRow("Strength", props.member.str, props.member.str_exp, Settings.theme.combat, classes)}
{generateTableRow("Defense", props.member.def, props.member.def_exp, Settings.theme.combat, classes)}
{generateTableRow("Dexterity", props.member.dex, props.member.dex_exp, Settings.theme.combat, classes)}
{generateTableRow("Agility", props.member.agi, props.member.agi_exp, Settings.theme.combat, classes)}
{generateTableRow("Charisma", props.member.cha, props.member.cha_exp, Settings.theme.cha, classes)}
</TableBody>
</Table>
</Tooltip>
</span>
<span>
<Select onChange={onChange} value={currentCategory} sx={{ width: '100%', mb: 1 }}>
{Object.keys(categories).map((k, i) => (
<MenuItem key={i + 1} value={k}>
<Typography variant="h6">{k}</Typography>
</MenuItem>
))}
</Select>
<Box sx={{ width: '100%' }}>
{(categories[currentCategory][0] as GangMemberUpgrade[]).length === 0 && (
<Typography>
All upgrades owned!
</Typography>
)}
<Box display="grid" sx={{ gridTemplateColumns: '1fr 1fr' }}>
{(categories[currentCategory][0] as GangMemberUpgrade[]).map((upg) => (
<UpgradeButton
key={upg.name}
rerender={rerender}
member={props.member}
upg={upg}
/>
))}
</Box>
<NextReveal
type={categories[currentCategory][1] as UpgradeType}
upgrades={props.member.upgrades}
/>
</Box>
</span>
</Box>
<Typography sx={{ mx: 1 }}>Purchased Upgrades: </Typography>
<Box display="grid" sx={{ gridTemplateColumns: 'repeat(4, 1fr)', m: 1 }}>
{props.member.upgrades.map((upg: string) => (
<PurchasedUpgrade key={upg} upgName={upg} />
))}
@ -146,59 +232,22 @@ function GangMemberUpgradePanel(props: IPanelProps): React.ReactElement {
<PurchasedUpgrade key={upg} upgName={upg} />
))}
</Box>
<Box display="flex" justifyContent="space-around">
<Box>
<Typography variant="h6" color="primary">
Weapons
</Typography>
{weaponUpgrades.map((upg) => (
<UpgradeButton key={upg.name} rerender={rerender} member={props.member} upg={upg} />
))}
<NextReveal type={UpgradeType.Weapon} upgrades={props.member.upgrades} />
</Box>
<Box>
<Typography variant="h6" color="primary">
Armor
</Typography>
{armorUpgrades.map((upg) => (
<UpgradeButton key={upg.name} rerender={rerender} member={props.member} upg={upg} />
))}
<NextReveal type={UpgradeType.Armor} upgrades={props.member.upgrades} />
</Box>
<Box>
<Typography variant="h6" color="primary">
Vehicles
</Typography>
{vehicleUpgrades.map((upg) => (
<UpgradeButton key={upg.name} rerender={rerender} member={props.member} upg={upg} />
))}
<NextReveal type={UpgradeType.Vehicle} upgrades={props.member.upgrades} />
</Box>
<Box>
<Typography variant="h6" color="primary">
Rootkits
</Typography>
{rootkitUpgrades.map((upg) => (
<UpgradeButton key={upg.name} rerender={rerender} member={props.member} upg={upg} />
))}
<NextReveal type={UpgradeType.Rootkit} upgrades={props.member.upgrades} />
</Box>
<Box>
<Typography variant="h6" color="primary">
Augmentations
</Typography>
{augUpgrades.map((upg) => (
<UpgradeButton key={upg.name} rerender={rerender} member={props.member} upg={upg} />
))}
<NextReveal type={UpgradeType.Augmentation} upgrades={props.member.upgrades} />
</Box>
</Box>
</Paper>
</Paper >
);
}
export function EquipmentsSubpage(): React.ReactElement {
const gang = useGang();
const [filter, setFilter] = useState("");
const handleFilterChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
setFilter(event.target.value.toLowerCase());
}
const members = gang.members
.filter((member) => member && member.name.toLowerCase().includes(filter));
return (
<>
<Tooltip
@ -209,11 +258,26 @@ export function EquipmentsSubpage(): React.ReactElement {
</Typography>
}
>
<Typography>Discount: -{numeralWrapper.formatPercentage(1 - 1 / gang.getDiscount())}</Typography>
<Typography sx={{ m: 1 }}>Discount: -{numeralWrapper.formatPercentage(1 - 1 / gang.getDiscount())}</Typography>
</Tooltip>
{gang.members.map((member: GangMember) => (
<TextField
value={filter}
onChange={handleFilterChange}
autoFocus
InputProps={{
startAdornment: <SearchIcon />,
spellCheck: false
}}
placeholder="Filter by member name"
sx={{ m: 1, width: '15%' }}
/>
<Box display="grid" sx={{ gridTemplateColumns: '1fr 1fr', width: 'fit-content' }}>
{members.map((member: GangMember) => (
<GangMemberUpgradePanel key={member.name} member={member} />
))}
</Box>
</>
);
}

@ -1,36 +0,0 @@
/**
* React Component for a gang member on the management subpage.
*/
import React, { useState } from "react";
import { GangMember } from "../GangMember";
import { GangMemberAccordionContent } from "./GangMemberAccordionContent";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemText from "@mui/material/ListItemText";
import Paper from "@mui/material/Paper";
import Collapse from "@mui/material/Collapse";
import ExpandMore from "@mui/icons-material/ExpandMore";
import ExpandLess from "@mui/icons-material/ExpandLess";
interface IProps {
member: GangMember;
}
export function GangMemberAccordion(props: IProps): React.ReactElement {
const [open, setOpen] = useState(true);
return (
<Box component={Paper}>
<ListItemButton onClick={() => setOpen((old) => !old)}>
<ListItemText primary={<Typography>{props.member.name}</Typography>} />
{open ? <ExpandLess color="primary" /> : <ExpandMore color="primary" />}
</ListItemButton>
<Collapse in={open} unmountOnExit>
<Box sx={{ mx: 4 }}>
<GangMemberAccordionContent member={props.member} />
</Box>
</Collapse>
</Box>
);
}

@ -1,31 +0,0 @@
/**
* React Component for the content of the accordion of gang members on the
* management subpage.
*/
import React, { useState } from "react";
import { GangMemberStats } from "./GangMemberStats";
import { TaskSelector } from "./TaskSelector";
import { TaskDescription } from "./TaskDescription";
import { GangMember } from "../GangMember";
import Grid from "@mui/material/Grid";
interface IProps {
member: GangMember;
}
export function GangMemberAccordionContent(props: IProps): React.ReactElement {
const setRerender = useState(false)[1];
return (
<Grid container>
<Grid item xs={4}>
<GangMemberStats onAscend={() => setRerender((old) => !old)} member={props.member} />
</Grid>
<Grid item xs={4}>
<TaskSelector onTaskChange={() => setRerender((old) => !old)} member={props.member} />
</Grid>
<Grid item xs={4}>
<TaskDescription member={props.member} />
</Grid>
</Grid>
);
}

@ -0,0 +1,26 @@
/**
* React Component for a gang member on the management subpage.
*/
import React from "react";
import { GangMember } from "../GangMember";
import { GangMemberCardContent } from "./GangMemberCardContent";
import Box from "@mui/material/Box";
import ListItemText from "@mui/material/ListItemText";
import Paper from "@mui/material/Paper";
interface IProps {
member: GangMember;
}
export function GangMemberCard(props: IProps): React.ReactElement {
return (
<Box component={Paper} sx={{ width: 'auto' }}>
<Box sx={{ m: 1 }}>
<ListItemText primary={<b>{props.member.name}</b>} />
<GangMemberCardContent member={props.member} />
</Box>
</Box>
);
}

@ -0,0 +1,62 @@
/**
* React Component for the content of the accordion of gang members on the
* management subpage.
*/
import React, { useState } from "react";
import { GangMemberStats } from "./GangMemberStats";
import { TaskSelector } from "./TaskSelector";
import { AscensionModal } from "./AscensionModal";
import { Box } from "@mui/system";
import { Button, Typography } from "@mui/material";
import HelpIcon from "@mui/icons-material/Help";
import { GangMember } from "../GangMember";
import { StaticModal } from "../../ui/React/StaticModal";
interface IProps {
member: GangMember;
}
export function GangMemberCardContent(props: IProps): React.ReactElement {
const setRerender = useState(false)[1];
const [helpOpen, setHelpOpen] = useState(false);
const [ascendOpen, setAscendOpen] = useState(false);
return (
<>
{props.member.canAscend() && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', my: 1 }}>
<Button onClick={() => setAscendOpen(true)} style={{ flexGrow: 1, borderRightWidth: 0 }}>Ascend</Button>
<AscensionModal
open={ascendOpen}
onClose={() => setAscendOpen(false)}
member={props.member}
onAscend={() => setRerender((old) => !old)}
/>
<Button onClick={() => setHelpOpen(true)} style={{ width: 'fit-content', borderLeftWidth: 0 }}>
<HelpIcon />
</Button>
<StaticModal open={helpOpen} onClose={() => setHelpOpen(false)}>
<Typography>
Ascending a Gang Member resets the member's progress and stats in exchange for a permanent boost to their
stat multipliers.
<br />
<br />
The additional stat multiplier that the Gang Member gains upon ascension is based on the amount of exp
they have.
<br />
<br />
Upon ascension, the member will lose all of its non-Augmentation Equipment and your gang will lose respect
equal to the total respect earned by the member.
</Typography>
</StaticModal>
</Box>
)}
<Box display="grid" sx={{ gridTemplateColumns: '1fr 1fr', width: '100%', gap: 1 }}>
<GangMemberStats member={props.member} />
<TaskSelector onTaskChange={() => setRerender((old) => !old)} member={props.member} />
</Box>
</>
);
}

@ -2,23 +2,63 @@
* React Component for the list of gang members on the management subpage.
*/
import React, { useState } from "react";
import { GangMemberAccordion } from "./GangMemberAccordion";
import { GangMember } from "../GangMember";
import { GangMemberCard } from "./GangMemberCard";
import { RecruitButton } from "./RecruitButton";
import { useGang } from "./Context";
import { Box, TextField } from "@mui/material";
import SearchIcon from "@mui/icons-material/Search";
import { GangMember } from "../GangMember";
import { OptionSwitch } from "../../ui/React/OptionSwitch";
export function GangMemberList(): React.ReactElement {
const gang = useGang();
const setRerender = useState(false)[1];
const [filter, setFilter] = useState("");
const [ascendOnly, setAscendOnly] = useState(false);
const handleFilterChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
setFilter(event.target.value.toLowerCase());
}
const members = gang.members
.filter((member) => member && member.name.toLowerCase().includes(filter))
.filter((member) => {
if (ascendOnly) return member.canAscend();
return true;
});
return (
<>
<RecruitButton onRecruit={() => setRerender((old) => !old)} />
<ul>
{gang.members.map((member: GangMember) => (
<GangMemberAccordion key={member.name} member={member} />
<TextField
value={filter}
onChange={handleFilterChange}
autoFocus
InputProps={{
startAdornment: <SearchIcon />,
spellCheck: false
}}
placeholder="Filter by member name"
sx={{ m: 1, width: '15%' }}
/>
<OptionSwitch
checked={ascendOnly}
onChange={(newValue) => (setAscendOnly(newValue))}
text="Show only ascendable"
tooltip={
<>
Filter the members list by whether or not the member
can be ascended.
</>
}
/>
<Box display="grid" sx={{ gridTemplateColumns: 'repeat(2, 1fr)' }}>
{members.map((member: GangMember) => (
<GangMemberCard key={member.name} member={member} />
))}
</ul>
</Box>
</>
);
}

@ -2,26 +2,53 @@
* React Component for the first part of a gang member details.
* Contains skills and exp.
*/
import React, { useState } from "react";
import { formatNumber } from "../../utils/StringHelperFunctions";
import { numeralWrapper } from "../../ui/numeralFormat";
import { GangMember } from "../GangMember";
import { AscensionModal } from "./AscensionModal";
import React from "react";
import { useGang } from "./Context";
import Typography from "@mui/material/Typography";
import Tooltip from "@mui/material/Tooltip";
import Button from "@mui/material/Button";
import { StaticModal } from "../../ui/React/StaticModal";
import IconButton from "@mui/material/IconButton";
import HelpIcon from "@mui/icons-material/Help";
import {
Table,
TableBody,
TableCell,
TableRow,
} from "@mui/material";
import { numeralWrapper } from "../../ui/numeralFormat";
import { GangMember } from "../GangMember";
import { Settings } from "../../Settings/Settings";
import { formatNumber } from "../../utils/StringHelperFunctions";
import { MoneyRate } from "../../ui/React/MoneyRate";
import { characterOverviewStyles as useStyles } from "../../ui/React/CharacterOverview";
interface IProps {
member: GangMember;
onAscend: () => void;
}
export const generateTableRow = (
name: string,
level: number,
exp: number,
color: string,
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
classes: any
): React.ReactElement => {
return (
<TableRow>
<TableCell classes={{ root: classes.cellNone }}>
<Typography style={{ color: color }}>{name}</Typography>
</TableCell>
<TableCell align="right" classes={{ root: classes.cellNone }}>
<Typography style={{ color: color }}>
{formatNumber(level, 0)} ({numeralWrapper.formatExp(exp)} exp)
</Typography>
</TableCell>
</TableRow>
)
}
export function GangMemberStats(props: IProps): React.ReactElement {
const [helpOpen, setHelpOpen] = useState(false);
const [ascendOpen, setAscendOpen] = useState(false);
const classes = useStyles();
const asc = {
hack: props.member.calculateAscensionMult(props.member.hack_asc_points),
@ -32,6 +59,16 @@ export function GangMemberStats(props: IProps): React.ReactElement {
cha: props.member.calculateAscensionMult(props.member.cha_asc_points),
};
const gang = useGang();
const data = [
[`Money:`, <MoneyRate money={5 * props.member.calculateMoneyGain(gang)} />],
[`Respect:`, `${numeralWrapper.formatRespect(5 * props.member.calculateRespectGain(gang))} / sec`],
[`Wanted Level:`, `${numeralWrapper.formatWanted(5 * props.member.calculateWantedLevelGain(gang))} / sec`],
[`Total Respect:`, `${numeralWrapper.formatRespect(props.member.earnedRespect)}`],
];
return (
<>
<Tooltip
@ -63,50 +100,32 @@ export function GangMemberStats(props: IProps): React.ReactElement {
</Typography>
}
>
<Typography>
Hacking: {formatNumber(props.member.hack, 0)} ({numeralWrapper.formatExp(props.member.hack_exp)} exp)
<Table sx={{ display: 'table', mb: 1, width: '100%' }}>
<TableBody>
{generateTableRow("Hacking", props.member.hack, props.member.hack_exp, Settings.theme.hack, classes)}
{generateTableRow("Strength", props.member.str, props.member.str_exp, Settings.theme.combat, classes)}
{generateTableRow("Defense", props.member.def, props.member.def_exp, Settings.theme.combat, classes)}
{generateTableRow("Dexterity", props.member.dex, props.member.dex_exp, Settings.theme.combat, classes)}
{generateTableRow("Agility", props.member.agi, props.member.agi_exp, Settings.theme.combat, classes)}
{generateTableRow("Charisma", props.member.cha, props.member.cha_exp, Settings.theme.cha, classes)}
<TableRow>
<TableCell classes={{ root: classes.cellNone }}>
<br />
Strength: {formatNumber(props.member.str, 0)} ({numeralWrapper.formatExp(props.member.str_exp)} exp)
<br />
Defense: {formatNumber(props.member.def, 0)} ({numeralWrapper.formatExp(props.member.def_exp)} exp)
<br />
Dexterity: {formatNumber(props.member.dex, 0)} ({numeralWrapper.formatExp(props.member.dex_exp)} exp)
<br />
Agility: {formatNumber(props.member.agi, 0)} ({numeralWrapper.formatExp(props.member.agi_exp)} exp)
<br />
Charisma: {formatNumber(props.member.cha, 0)} ({numeralWrapper.formatExp(props.member.cha_exp)} exp)
<br />
</Typography>
</TableCell>
</TableRow>
{data.map(([a, b]) => (
<TableRow key={a.toString() + b.toString()}>
<TableCell classes={{ root: classes.cellNone }}>
<Typography>{a}</Typography>
</TableCell>
<TableCell align="right" classes={{ root: classes.cellNone }}>
<Typography>{b}</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Tooltip>
<br />
{props.member.canAscend() && (
<>
<Button onClick={() => setAscendOpen(true)}>Ascend</Button>
<AscensionModal
open={ascendOpen}
onClose={() => setAscendOpen(false)}
member={props.member}
onAscend={props.onAscend}
/>
<IconButton onClick={() => setHelpOpen(true)}>
<HelpIcon />
</IconButton>
<StaticModal open={helpOpen} onClose={() => setHelpOpen(false)}>
<Typography>
Ascending a Gang Member resets the member's progress and stats in exchange for a permanent boost to their
stat multipliers.
<br />
<br />
The additional stat multiplier that the Gang Member gains upon ascension is based on the amount of exp
they have.
<br />
<br />
Upon ascension, the member will lose all of its non-Augmentation Equipment and your gang will lose respect
equal to the total respect earned by the member.
</Typography>
</StaticModal>
</>
)}
</>
);
}

@ -24,18 +24,20 @@ export function RecruitButton(props: IProps): React.ReactElement {
if (!gang.canRecruitMember()) {
const respect = gang.getRespectNeededToRecruitMember();
return (
<Box display="flex" alignItems="center">
<Button sx={{ mx: 1 }} disabled>
<Box display="flex" alignItems="center" sx={{ mx: 1 }}>
<Button disabled>
Recruit Gang Member
</Button>
<Typography>{numeralWrapper.formatRespect(respect)} respect needed to recruit next member</Typography>
<Typography sx={{ ml: 1 }}>{numeralWrapper.formatRespect(respect)} respect needed to recruit next member</Typography>
</Box>
);
}
return (
<>
<Box sx={{ mx: 1 }}>
<Button onClick={() => setOpen(true)}>Recruit Gang Member</Button>
</Box>
<RecruitModal open={open} onClose={() => setOpen(false)} onRecruit={props.onRecruit} />
</>
);

@ -3,14 +3,15 @@
* the task selector as well as some stats.
*/
import React, { useState } from "react";
import { numeralWrapper } from "../../ui/numeralFormat";
import { StatsTable } from "../../ui/React/StatsTable";
import { MoneyRate } from "../../ui/React/MoneyRate";
import { useGang } from "./Context";
import { GangMember } from "../GangMember";
import { TaskDescription } from "./TaskDescription";
import { Box } from "@mui/material";
import MenuItem from "@mui/material/MenuItem";
import Select, { SelectChangeEvent } from "@mui/material/Select";
import { GangMember } from "../GangMember";
interface IProps {
member: GangMember;
onTaskChange: () => void;
@ -29,16 +30,9 @@ export function TaskSelector(props: IProps): React.ReactElement {
const tasks = gang.getAllTaskNames();
const data = [
[`Money:`, <MoneyRate money={5 * props.member.calculateMoneyGain(gang)} />],
[`Respect:`, `${numeralWrapper.formatRespect(5 * props.member.calculateRespectGain(gang))} / sec`],
[`Wanted Level:`, `${numeralWrapper.formatWanted(5 * props.member.calculateWantedLevelGain(gang))} / sec`],
[`Total Respect:`, `${numeralWrapper.formatRespect(props.member.earnedRespect)}`],
];
return (
<>
<Select onChange={onChange} value={currentTask}>
<Box>
<Select onChange={onChange} value={currentTask} sx={{ width: '100%' }}>
<MenuItem key={0} value={"Unassigned"}>
Unassigned
</MenuItem>
@ -48,8 +42,7 @@ export function TaskSelector(props: IProps): React.ReactElement {
</MenuItem>
))}
</Select>
<StatsTable rows={data} />
</>
<TaskDescription member={props.member} />
</Box>
);
}

@ -34,9 +34,9 @@ export function calculateHackingExpGain(server: Server, player: IPlayer): number
server.baseDifficulty = server.hackDifficulty;
}
let expGain = baseExpGain;
expGain += server.baseDifficulty * player.hacking_exp_mult * diffFactor;
expGain += server.baseDifficulty * diffFactor;
return expGain * BitNodeMultipliers.HackExpGain;
return expGain * player.hacking_exp_mult * BitNodeMultipliers.HackExpGain;
}
/**

@ -5,7 +5,7 @@
*/
import React, { useState } from "react";
import Button from "@mui/material/Button";
import { Blackjack } from "../../Casino/Blackjack";
import { Blackjack, DECK_COUNT } from "../../Casino/Blackjack";
import { CoinFlip } from "../../Casino/CoinFlip";
import { Roulette } from "../../Casino/Roulette";
import { SlotMachine } from "../../Casino/SlotMachine";
@ -38,7 +38,7 @@ export function CasinoLocation(props: IProps): React.ReactElement {
<Button onClick={() => updateGame(GameType.Coin)}>Play coin flip</Button>
<Button onClick={() => updateGame(GameType.Slots)}>Play slots</Button>
<Button onClick={() => updateGame(GameType.Roulette)}>Play roulette</Button>
<Button onClick={() => updateGame(GameType.Blackjack)}>Play blackjack</Button>
<Button onClick={() => updateGame(GameType.Blackjack)}>Play blackjack ({DECK_COUNT} decks)</Button>
</Box>
)}
{game !== GameType.None && (

@ -114,6 +114,7 @@ export const RamCosts: IMap<any> = {
weaken: RamCostConstants.ScriptWeakenRamCost,
weakenAnalyze: RamCostConstants.ScriptWeakenAnalyzeRamCost,
print: 0,
printf: 0,
tprint: 0,
clearLog: 0,
disableLog: 0,

@ -553,7 +553,7 @@ export function NetscriptFunctions(workerScript: WorkerScript): NS {
if (isNaN(hackAmount)) {
throw makeRuntimeErrorMsg(
"hackAnalyzeThreads",
`Invalid growth argument passed into hackAnalyzeThreads: ${hackAmount}. Must be numeric.`,
`Invalid hackAmount argument passed into hackAnalyzeThreads: ${hackAmount}. Must be numeric.`,
);
}
@ -751,6 +751,12 @@ export function NetscriptFunctions(workerScript: WorkerScript): NS {
}
workerScript.print(argsToString(args));
},
printf: function (format: string, ...args: any[]): void {
if (typeof format !== "string") {
throw makeRuntimeErrorMsg("printf", "First argument must be string for the format.");
}
workerScript.print(vsprintf(format, args));
},
tprint: function (...args: any[]): void {
if (args.length === 0) {
throw makeRuntimeErrorMsg("tprint", "Takes at least 1 argument.");
@ -1676,7 +1682,12 @@ export function NetscriptFunctions(workerScript: WorkerScript): NS {
const cost = getPurchaseServerCost(ram);
if (cost === Infinity) {
if(ram > getPurchaseServerMaxRam()){
workerScript.log("purchaseServer", () => `Invalid argument: ram='${ram}' must not be greater than getPurchaseServerMaxRam`);
}else{
workerScript.log("purchaseServer", () => `Invalid argument: ram='${ram}' must be a positive power of 2`);
}
return "";
}

@ -49,6 +49,7 @@ import {
SetMaterialMarketTA2,
SetProductMarketTA1,
SetProductMarketTA2,
SetSmartSupplyUseLeftovers,
} from "../Corporation/Actions";
import { CorporationUnlockUpgrades } from "../Corporation/data/CorporationUnlockUpgrades";
import { CorporationUpgrades } from "../Corporation/data/CorporationUpgrades";
@ -410,6 +411,16 @@ export function NetscriptCorporation(
const warehouse = getWarehouse(divisionName, cityName);
SetSmartSupply(warehouse, enabled);
},
setSmartSupplyUseLeftovers: function (adivisionName: any, acityName: any, amaterialName: any, aenabled: any): void {
checkAccess("setSmartSupplyUseLeftovers", 7);
const divisionName = helper.string("setSmartSupply", "divisionName", adivisionName);
const cityName = helper.string("sellProduct", "cityName", acityName);
const materialName = helper.string("sellProduct", "materialName", amaterialName);
const enabled = helper.boolean(aenabled);
const warehouse = getWarehouse(divisionName, cityName);
const material = getMaterial(divisionName, cityName, materialName);
SetSmartSupplyUseLeftovers(warehouse, material, enabled);
},
buyMaterial: function (adivisionName: any, acityName: any, amaterialName: any, aamt: any): void {
checkAccess("buyMaterial", 7);
const divisionName = helper.string("buyMaterial", "divisionName", adivisionName);

@ -81,41 +81,41 @@ export function NetscriptFormulas(player: IPlayer, workerScript: WorkerScript, h
return {
skills: {
calculateSkill: function (exp: any, mult: any = 1): any {
checkFormulasAccess("basic.calculateSkill");
checkFormulasAccess("skills.calculateSkill");
return calculateSkill(exp, mult);
},
calculateExp: function (skill: any, mult: any = 1): any {
checkFormulasAccess("basic.calculateExp");
checkFormulasAccess("skills.calculateExp");
return calculateExp(skill, mult);
},
},
hacking: {
hackChance: function (server: any, player: any): any {
checkFormulasAccess("basic.hackChance");
checkFormulasAccess("hacking.hackChance");
return calculateHackingChance(server, player);
},
hackExp: function (server: any, player: any): any {
checkFormulasAccess("basic.hackExp");
checkFormulasAccess("hacking.hackExp");
return calculateHackingExpGain(server, player);
},
hackPercent: function (server: any, player: any): any {
checkFormulasAccess("basic.hackPercent");
checkFormulasAccess("hacking.hackPercent");
return calculatePercentMoneyHacked(server, player);
},
growPercent: function (server: any, threads: any, player: any, cores: any = 1): any {
checkFormulasAccess("basic.growPercent");
checkFormulasAccess("hacking.growPercent");
return calculateServerGrowth(server, threads, player, cores);
},
hackTime: function (server: any, player: any): any {
checkFormulasAccess("basic.hackTime");
checkFormulasAccess("hacking.hackTime");
return calculateHackingTime(server, player) * 1000;
},
growTime: function (server: any, player: any): any {
checkFormulasAccess("basic.growTime");
checkFormulasAccess("hacking.growTime");
return calculateGrowTime(server, player) * 1000;
},
weakenTime: function (server: any, player: any): any {
checkFormulasAccess("basic.weakenTime");
checkFormulasAccess("hacking.weakenTime");
return calculateWeakenTime(server, player) * 1000;
},
},
@ -188,21 +188,27 @@ export function NetscriptFormulas(player: IPlayer, workerScript: WorkerScript, h
},
gang: {
wantedPenalty(gang: any): number {
checkFormulasAccess("gang.wantedPenalty");
return calculateWantedPenalty(gang);
},
respectGain: function (gang: any, member: any, task: any): number {
checkFormulasAccess("gang.respectGain");
return calculateRespectGain(gang, member, task);
},
wantedLevelGain: function (gang: any, member: any, task: any): number {
checkFormulasAccess("gang.wantedLevelGain");
return calculateWantedLevelGain(gang, member, task);
},
moneyGain: function (gang: any, member: any, task: any): number {
checkFormulasAccess("gang.moneyGain");
return calculateMoneyGain(gang, member, task);
},
ascensionPointsGain: function (exp: any): number {
checkFormulasAccess("gang.ascensionPointsGain");
return calculateAscensionPointsGain(exp);
},
ascensionMultiplier: function (points: any): number {
checkFormulasAccess("gang.ascensionMultiplier");
return calculateAscensionMult(points);
},
},

@ -474,7 +474,8 @@ export function NetscriptSingularity(
case CityName.Ishima:
case CityName.Volhaven:
if (player.money < CONSTANTS.TravelCost) {
throw helper.makeRuntimeErrorMsg("travelToCity", "Not enough money to travel.");
workerScript.log("travelToCity", () => "Not enough money to travel.");
return false
}
player.loseMoney(CONSTANTS.TravelCost, "other");
player.city = cityname;
@ -482,8 +483,7 @@ export function NetscriptSingularity(
player.gainIntelligenceExp(CONSTANTS.IntelligenceSingFnBaseExpGain / 50000);
return true;
default:
workerScript.log("travelToCity", () => `Invalid city name: '${cityname}'.`);
return false;
throw helper.makeRuntimeErrorMsg("travelToCity", `Invalid city name: '${cityname}'.`);
}
},

@ -4,9 +4,9 @@ import { IPlayer } from "../PersonObjects/IPlayer";
import { getRamCost } from "../Netscript/RamCostGenerator";
import { GameInfo, IStyleSettings, UserInterface as IUserInterface, UserInterfaceTheme } from "../ScriptEditor/NetscriptDefinitions";
import { Settings } from "../Settings/Settings";
import { ThemeEvents } from "../ui/React/Theme";
import { defaultTheme } from "../Settings/Themes";
import { defaultStyles } from "../Settings/Styles";
import { ThemeEvents } from "../Themes/ui/Theme";
import { defaultTheme } from "../Themes/Themes";
import { defaultStyles } from "../Themes/Styles";
import { CONSTANTS } from "../Constants";
import { hash } from "../hash/hash";

@ -72,6 +72,7 @@ export interface IPlayer {
sourceFiles: IPlayerOwnedSourceFile[];
exploits: Exploit[];
achievements: PlayerAchievement[];
terminalCommandHistory: string[];
lastUpdate: number;
totalPlaytime: number;

@ -35,7 +35,9 @@ import { CityName } from "../../Locations/data/CityNames";
import { MoneySourceTracker } from "../../utils/MoneySourceTracker";
import { Reviver, Generic_toJSON, Generic_fromJSON } from "../../utils/JSONReviver";
import { ISkillProgress } from "../formulas/skill";
import { PlayerAchievement } from '../../Achievements/Achievements';
import { PlayerAchievement } from "../../Achievements/Achievements";
import { cyrb53 } from "../../utils/StringHelperFunctions";
import { getRandomInt } from "../../utils/helpers/getRandomInt";
export class PlayerObject implements IPlayer {
// Class members
@ -77,7 +79,10 @@ export class PlayerObject implements IPlayer {
sourceFiles: IPlayerOwnedSourceFile[];
exploits: Exploit[];
achievements: PlayerAchievement[];
terminalCommandHistory: string[];
identifier: string;
lastUpdate: number;
lastSave: number;
totalPlaytime: number;
// Stats
@ -459,7 +464,9 @@ export class PlayerObject implements IPlayer {
//Used to store the last update time.
this.lastUpdate = 0;
this.lastSave = 0;
this.totalPlaytime = 0;
this.playtimeSinceLastAug = 0;
this.playtimeSinceLastBitnode = 0;
@ -471,6 +478,17 @@ export class PlayerObject implements IPlayer {
this.exploits = [];
this.achievements = [];
this.terminalCommandHistory = [];
// Let's get a hash of some semi-random stuff so we have something unique.
this.identifier = cyrb53(
"I-" +
new Date().getTime() +
navigator.userAgent +
window.innerWidth +
window.innerHeight +
getRandomInt(100, 999),
);
this.init = generalMethods.init;
this.prestigeAugmentation = generalMethods.prestigeAugmentation;

@ -1,5 +1,8 @@
import { Sleeve } from "../Sleeve";
import { numeralWrapper } from "../../../ui/numeralFormat";
import { convertTimeMsToTimeElapsedString } from "../../../utils/StringHelperFunctions";
import { CONSTANTS } from "../../../Constants";
import { Typography } from "@mui/material";
import { StatsTable } from "../../../ui/React/StatsTable";
import { Modal } from "../../../ui/React/Modal";
import React from "react";
@ -80,6 +83,13 @@ export function MoreStatsModal(props: IProps): React.ReactElement {
]}
title="Multipliers:"
/>
{/* Check for storedCycles to be a bit over 0 to prevent jittering */}
{props.sleeve.storedCycles > 10 && (
<Typography sx={{ py: 2 }}>
Bonus Time: {convertTimeMsToTimeElapsedString(props.sleeve.storedCycles * CONSTANTS.MilliPerCycle)}
</Typography>
)}
</Modal>
);
}

@ -22,11 +22,44 @@ import { v1APIBreak } from "./utils/v1APIBreak";
import { AugmentationNames } from "./Augmentation/data/AugmentationNames";
import { PlayerOwnedAugmentation } from "./Augmentation/PlayerOwnedAugmentation";
import { LocationName } from "./Locations/data/LocationNames";
import { SxProps } from "@mui/system";
import { PlayerObject } from "./PersonObjects/Player/PlayerObject";
import { pushGameSaved } from "./Electron";
/* SaveObject.js
* Defines the object used to save/load games
*/
export interface SaveData {
playerIdentifier: string;
fileName: string;
save: string;
savedOn: number;
}
export interface ImportData {
base64: string;
parsed: any;
playerData?: ImportPlayerData;
}
export interface ImportPlayerData {
identifier: string;
lastSave: number;
totalPlaytime: number;
money: number;
hacking: number;
augmentations: number;
factions: number;
achievements: number;
bitNode: number;
bitNodeLevel: number;
sourceFiles: number;
}
class BitburnerSaveObject {
PlayerSave = "";
AllServersSave = "";
@ -41,7 +74,6 @@ class BitburnerSaveObject {
AllGangsSave = "";
LastExportBonus = "";
StaneksGiftSave = "";
SaveTimestamp = "";
getSaveString(excludeRunningScripts = false): string {
this.PlayerSave = JSON.stringify(Player);
@ -57,7 +89,6 @@ class BitburnerSaveObject {
this.VersionSave = JSON.stringify(CONSTANTS.VersionNumber);
this.LastExportBonus = JSON.stringify(ExportBonus.LastExportBonus);
this.StaneksGiftSave = JSON.stringify(staneksGift);
this.SaveTimestamp = new Date().getTime().toString();
if (Player.inGang()) {
this.AllGangsSave = JSON.stringify(AllGangs);
@ -67,28 +98,134 @@ class BitburnerSaveObject {
return saveString;
}
saveGame(emitToastEvent = true): void {
saveGame(emitToastEvent = true): Promise<void> {
const savedOn = new Date().getTime();
Player.lastSave = savedOn;
const saveString = this.getSaveString(Settings.ExcludeRunningScriptsFromSave);
return new Promise((resolve, reject) => {
save(saveString)
.then(() => {
const saveData: SaveData = {
playerIdentifier: Player.identifier,
fileName: this.getSaveFileName(),
save: saveString,
savedOn,
};
pushGameSaved(saveData);
if (emitToastEvent) {
SnackbarEvents.emit("Game Saved!", "info", 2000);
}
return resolve();
})
.catch((err) => console.error(err));
.catch((err) => {
console.error(err);
return reject();
});
});
}
getSaveFileName(isRecovery = false): string {
// Save file name is based on current timestamp and BitNode
const epochTime = Math.round(Date.now() / 1000);
const bn = Player.bitNodeN;
let filename = `bitburnerSave_${epochTime}_BN${bn}x${SourceFileFlags[bn]}.json`;
if (isRecovery) filename = "RECOVERY" + filename;
return filename;
}
exportGame(): void {
const saveString = this.getSaveString(Settings.ExcludeRunningScriptsFromSave);
// Save file name is based on current timestamp and BitNode
const epochTime = Math.round(Date.now() / 1000);
const bn = Player.bitNodeN;
const filename = `bitburnerSave_${epochTime}_BN${bn}x${SourceFileFlags[bn]}.json`;
const filename = this.getSaveFileName();
download(filename, saveString);
}
importGame(base64Save: string, reload = true): Promise<void> {
if (!base64Save || base64Save === "") throw new Error("Invalid import string");
return save(base64Save).then(() => {
if (reload) setTimeout(() => location.reload(), 1000);
return Promise.resolve();
});
}
getImportStringFromFile(files: FileList | null): Promise<string> {
if (files === null) return Promise.reject(new Error("No file selected"));
const file = files[0];
if (!file) return Promise.reject(new Error("Invalid file selected"));
const reader = new FileReader();
const promise: Promise<string> = new Promise((resolve, reject) => {
reader.onload = function (this: FileReader, e: ProgressEvent<FileReader>) {
const target = e.target;
if (target === null) {
return reject(new Error("Error importing file"));
}
const result = target.result;
if (typeof result !== "string" || result === null) {
return reject(new Error("FileReader event was not type string"));
}
const contents = result;
resolve(contents);
};
});
reader.readAsText(file);
return promise;
}
async getImportDataFromString(base64Save: string): Promise<ImportData> {
if (!base64Save || base64Save === "") throw new Error("Invalid import string");
let newSave;
try {
newSave = window.atob(base64Save);
newSave = newSave.trim();
} catch (error) {
console.error(error); // We'll handle below
}
if (!newSave || newSave === "") {
return Promise.reject(new Error("Save game had not content or was not base64 encoded"));
}
let parsedSave;
try {
parsedSave = JSON.parse(newSave);
} catch (error) {
console.log(error); // We'll handle below
}
if (!parsedSave || parsedSave.ctor !== "BitburnerSaveObject" || !parsedSave.data) {
return Promise.reject(new Error("Save game did not seem valid"));
}
const data: ImportData = {
base64: base64Save,
parsed: parsedSave,
};
const importedPlayer = PlayerObject.fromJSON(JSON.parse(parsedSave.data.PlayerSave));
const playerData: ImportPlayerData = {
identifier: importedPlayer.identifier,
lastSave: importedPlayer.lastSave,
totalPlaytime: importedPlayer.totalPlaytime,
money: importedPlayer.money,
hacking: importedPlayer.hacking,
augmentations: importedPlayer.augmentations?.reduce<number>((total, current) => (total += current.level), 0) ?? 0,
factions: importedPlayer.factions?.length ?? 0,
achievements: importedPlayer.achievements?.length ?? 0,
bitNode: importedPlayer.bitNodeN,
bitNodeLevel: importedPlayer.sourceFileLvl(Player.bitNodeN) + 1,
sourceFiles: importedPlayer.sourceFiles?.reduce<number>((total, current) => (total += current.lvl), 0) ?? 0,
};
data.playerData = playerData;
return Promise.resolve(data);
}
toJSON(): any {
return Generic_toJSON("BitburnerSaveObject", this);
}
@ -371,6 +508,18 @@ function createScamUpdateText(): void {
}
}
const resets: SxProps = {
"& h1, & h2, & h3, & h4, & p, & a, & ul": {
margin: 0,
color: Settings.theme.primary,
whiteSpace: "initial",
},
"& ul": {
paddingLeft: "1.5em",
lineHeight: 1.5,
},
};
function createNewUpdateText(): void {
setTimeout(
() =>
@ -379,6 +528,7 @@ function createNewUpdateText(): void {
"Please report any bugs/issues through the github repository " +
"or the Bitburner subreddit (reddit.com/r/bitburner).<br><br>" +
CONSTANTS.LatestUpdate,
resets,
),
1000,
);
@ -391,6 +541,7 @@ function createBetaUpdateText(): void {
"Please report any bugs/issues through the github repository (https://github.com/danielyxie/bitburner/issues) " +
"or the Bitburner subreddit (reddit.com/r/bitburner).<br><br>" +
CONSTANTS.LatestUpdate,
resets,
);
}

@ -163,7 +163,7 @@ export interface CrimeStats {
/** How much money is given */
money: number;
/** Name of crime */
name: number;
name: string;
/** Milliseconds it takes to attempt the crime */
time: number;
/** Description of the crime activity */
@ -3667,6 +3667,7 @@ interface SkillsFormulas {
interface HackingFormulas {
/**
* Calculate hack chance.
* (Ex: 0.25 would indicate a 25% chance of success.)
* @param server - Server info from {@link NS.getServer | getServer}
* @param player - Player info from {@link NS.getPlayer | getPlayer}
* @returns The calculated hack chance.
@ -3683,6 +3684,7 @@ interface HackingFormulas {
hackExp(server: Server, player: Player): number;
/**
* Calculate hack percent for one thread.
* (Ex: 0.25 would steal 25% of the server's current value.)
* @remarks
* Multiply by thread to get total percent hacked.
* @param server - Server info from {@link NS.getServer | getServer}
@ -3691,7 +3693,8 @@ interface HackingFormulas {
*/
hackPercent(server: Server, player: Player): number;
/**
* Calculate the percent a server would grow.
* Calculate the percent a server would grow to.
* (Ex: 3.0 would would grow the server to 300% of its current value.)
* @param server - Server info from {@link NS.getServer | getServer}
* @param threads - Amount of thread.
* @param player - Player info from {@link NS.getPlayer | getPlayer}
@ -4231,13 +4234,11 @@ export interface NS extends Singularity {
* ```ts
* // NS1:
* var earnedMoney = hack("foodnstuff");
* earnedMoney = earnedMoney + hack("foodnstuff", { threads: 5 }); // Only use 5 threads to hack
* ```
* @example
* ```ts
* // NS2:
* let earnedMoney = await ns.hack("foodnstuff");
* earnedMoney += await ns.hack("foodnstuff", { threads: 5 }); // Only use 5 threads to hack
* ```
* @param host - Hostname of the target server to hack.
* @param opts - Optional parameters for configuring function behavior.
@ -4265,16 +4266,14 @@ export interface NS extends Singularity {
* @example
* ```ts
* // NS1:
* var availableMoney = getServerMoneyAvailable("foodnstuff");
* var currentMoney = getServerMoneyAvailable("foodnstuff");
* currentMoney = currentMoney * (1 + grow("foodnstuff"));
* currentMoney = currentMoney * (1 + grow("foodnstuff", { threads: 5 })); // Only use 5 threads to grow
* ```
* @example
* ```ts
* // NS2:
* let availableMoney = ns.getServerMoneyAvailable("foodnstuff");
* let currentMoney = ns.getServerMoneyAvailable("foodnstuff");
* currentMoney *= (1 + await ns.grow("foodnstuff"));
* currentMoney *= (1 + await ns.grow("foodnstuff", { threads: 5 })); // Only use 5 threads to grow
* ```
* @param host - Hostname of the target server to grow.
* @param opts - Optional parameters for configuring function behavior.
@ -4300,14 +4299,12 @@ export interface NS extends Singularity {
* // NS1:
* var currentSecurity = getServerSecurityLevel("foodnstuff");
* currentSecurity = currentSecurity - weaken("foodnstuff");
* currentSecurity = currentSecurity - weaken("foodnstuff", { threads: 5 }); // Only use 5 threads to weaken
* ```
* @example
* ```ts
* // NS2:
* let currentSecurity = ns.getServerSecurityLevel("foodnstuff");
* currentSecurity -= await ns.weaken("foodnstuff");
* currentSecurity -= await ns.weaken("foodnstuff", { threads: 5 }); // Only use 5 threads to weaken
* ```
* @param host - Hostname of the target server to weaken.
* @param opts - Optional parameters for configuring function behavior.
@ -4494,6 +4491,17 @@ export interface NS extends Singularity {
*/
print(...args: any[]): void;
/**
* Prints a formatted string to the scripts logs.
* @remarks
* RAM cost: 0 GB
*
* see: https://github.com/alexei/sprintf.js
* @param format - format of the message
* @param args - Value(s) to be printed.
*/
printf(format: string, ...args: any[]): void;
/**
* Prints one or more values or variables to the Terminal.
* @remarks
@ -5451,7 +5459,7 @@ export interface NS extends Singularity {
* @param filename - Optional. Filename or PID of the script.
* @param hostname - Optional. Name of host server the script is running on.
* @param args - Arguments to identify the script
* @returns info about a running script
* @returns The info about the running script if found, and null otherwise.
*/
getRunningScript(filename?: FilenameOrPID, hostname?: string, ...args: (string | number)[]): RunningScript;
@ -6234,14 +6242,14 @@ export interface OfficeAPI {
/**
* Get the cost to unlock research
* @param divisionName - Name of the division
* @param cityName - Name of the city
* @param researchName - Name of the research
* @returns cost
*/
getResearchCost(divisionName: string, researchName: string): number;
/**
* Gets if you have unlocked a research
* @param divisionName - Name of the division
* @param cityName - Name of the city
* @param researchName - Name of the research
* @returns true is unlocked, false if not
*/
hasResearched(divisionName: string, researchName: string): boolean;
@ -6310,6 +6318,14 @@ export interface WarehouseAPI {
* @param enabled - smart supply enabled
*/
setSmartSupply(divisionName: string, cityName: string, enabled: boolean): void;
/**
* Set whether smart supply uses leftovers before buying
* @param divisionName - Name of the division
* @param cityName - Name of the city
* @param materialName - Name of the material
* @param enabled - smart supply use leftovers enabled
*/
setSmartSupplyUseLeftovers(divisionName: string, cityName: string, materialName: string, enabled: boolean): void;
/**
* Set material buy data
* @param divisionName - Name of the division

@ -33,6 +33,8 @@ import Typography from "@mui/material/Typography";
import Link from "@mui/material/Link";
import Box from "@mui/material/Box";
import SettingsIcon from "@mui/icons-material/Settings";
import SyncIcon from "@mui/icons-material/Sync";
import CloseIcon from "@mui/icons-material/Close";
import Table from "@mui/material/Table";
import TableCell from "@mui/material/TableCell";
import TableRow from "@mui/material/TableRow";
@ -41,6 +43,7 @@ import { PromptEvent } from "../../ui/React/PromptManager";
import { Modal } from "../../ui/React/Modal";
import libSource from "!!raw-loader!../NetscriptDefinitions.d.ts";
import { Tooltip } from "@mui/material";
interface IProps {
// Map of filename -> code
@ -696,17 +699,58 @@ export function Root(props: IProps): React.ReactElement {
}
}
function onTabUpdate(index: number): void {
const openScript = openScripts[index];
const serverScriptCode = getServerCode(index);
if (serverScriptCode === null) return;
if (openScript.code !== serverScriptCode) {
PromptEvent.emit({
txt:
"Do you want to overwrite the current editor content with the contents of " +
openScript.fileName +
" on the server? This cannot be undone.",
resolve: (result: boolean) => {
if (result) {
// Save changes
openScript.code = serverScriptCode;
// Switch to target tab
onTabClick(index);
if (editorRef.current !== null && openScript !== null) {
if (openScript.model === undefined || openScript.model.isDisposed()) {
regenerateModel(openScript);
}
editorRef.current.setModel(openScript.model);
editorRef.current.setValue(openScript.code);
updateRAM(openScript.code);
editorRef.current.focus();
}
}
},
});
}
}
function dirty(index: number): string {
const openScript = openScripts[index];
const serverScriptCode = getServerCode(index);
if (serverScriptCode === null) return " *";
// The server code is stored with its starting & trailing whitespace removed
const openScriptFormatted = Script.formatCode(openScript.code);
return serverScriptCode !== openScriptFormatted ? " *" : "";
}
function getServerCode(index: number): string | null {
const openScript = openScripts[index];
const server = GetServer(openScript.hostname);
if (server === null) throw new Error(`Server '${openScript.hostname}' should not be null, but it is.`);
const serverScript = server.scripts.find((s) => s.filename === openScript.fileName);
if (serverScript === undefined) return " *";
// The server code is stored with its starting & trailing whitespace removed
const openScriptFormatted = Script.formatCode(openScript.code);
return serverScript.code !== openScriptFormatted ? " *" : "";
return serverScript?.code ?? null;
}
// Toolbars are roughly 112px:
@ -738,7 +782,25 @@ export function Root(props: IProps): React.ReactElement {
overflowX: "scroll",
}}
>
{openScripts.map(({ fileName, hostname }, index) => (
{openScripts.map(({ fileName, hostname }, index) => {
const iconButtonStyle = {
maxWidth: "25px",
minWidth: "25px",
minHeight: "38.5px",
maxHeight: "38.5px",
...(currentScript?.fileName === openScripts[index].fileName
? {
background: Settings.theme.button,
borderColor: Settings.theme.button,
color: Settings.theme.primary,
}
: {
background: Settings.theme.backgroundsecondary,
borderColor: Settings.theme.backgroundsecondary,
color: Settings.theme.secondary,
}),
};
return (
<Draggable
key={fileName + hostname}
draggableId={fileName + hostname}
@ -754,46 +816,44 @@ export function Root(props: IProps): React.ReactElement {
...provided.draggableProps.style,
marginRight: "5px",
flexShrink: 0,
border: "1px solid " + Settings.theme.well,
}}
>
<Button
onClick={() => onTabClick(index)}
style={
currentScript?.fileName === openScripts[index].fileName
? {
background: Settings.theme.button,
color: Settings.theme.primary,
}
: {
background: Settings.theme.backgroundsecondary,
color: Settings.theme.secondary,
}
}
>
{hostname}:~/{fileName} {dirty(index)}
</Button>
<Button
onClick={() => onTabClose(index)}
onMouseDown={(e) => {
e.preventDefault();
if (e.button === 1) onTabClose(index);
}}
style={{
maxWidth: "20px",
minWidth: "20px",
...(currentScript?.fileName === openScripts[index].fileName
? {
background: Settings.theme.button,
borderColor: Settings.theme.button,
color: Settings.theme.primary,
}
: {
background: Settings.theme.backgroundsecondary,
borderColor: Settings.theme.backgroundsecondary,
color: Settings.theme.secondary,
}),
}}
>
x
{hostname}:~/{fileName} {dirty(index)}
</Button>
<Tooltip title="Overwrite editor content with saved file content">
<Button onClick={() => onTabUpdate(index)} style={iconButtonStyle}>
<SyncIcon fontSize="small" />
</Button>
</Tooltip>
<Button onClick={() => onTabClose(index)} style={iconButtonStyle}>
<CloseIcon fontSize="small" />
</Button>
</div>
)}
</Draggable>
))}
);
})}
{provided.placeholder}
</Box>
)}

@ -30,7 +30,7 @@ export function getPurchaseServerCost(ram: number): number {
const upg = Math.max(0, Math.log(sanitizedRam) / Math.log(2) - 6);
return (
return Math.round(
sanitizedRam *
CONSTANTS.BaseCostFor1GBOfRamServer *
BitNodeMultipliers.PurchasedServerCost *

@ -1,7 +1,7 @@
import { ISelfInitializer, ISelfLoading } from "../types";
import { OwnedAugmentationsOrderSetting, PurchaseAugmentationsOrderSetting } from "./SettingEnums";
import { defaultTheme, ITheme } from "./Themes";
import { defaultStyles } from "./Styles";
import { defaultTheme, ITheme } from "../Themes/Themes";
import { defaultStyles } from "../Themes/Styles";
import { WordWrapOptions } from "../ScriptEditor/ui/Options";
import { OverviewSettings } from "../ui/React/Overview";
import { IStyleSettings } from "../ScriptEditor/NetscriptDefinitions";

@ -1,613 +0,0 @@
import { IMap } from "../types";
export interface ITheme {
[key: string]: string | undefined;
primarylight: string;
primary: string;
primarydark: string;
successlight: string;
success: string;
successdark: string;
errorlight: string;
error: string;
errordark: string;
secondarylight: string;
secondary: string;
secondarydark: string;
warninglight: string;
warning: string;
warningdark: string;
infolight: string;
info: string;
infodark: string;
welllight: string;
well: string;
white: string;
black: string;
hp: string;
money: string;
hack: string;
combat: string;
cha: string;
int: string;
rep: string;
disabled: string;
backgroundprimary: string;
backgroundsecondary: string;
button: string;
}
export interface IPredefinedTheme {
colors: ITheme;
name?: string;
credit?: string;
description?: string;
reference?: string;
}
export const defaultTheme: ITheme = {
primarylight: "#0f0",
primary: "#0c0",
primarydark: "#090",
successlight: "#0f0",
success: "#0c0",
successdark: "#090",
errorlight: "#f00",
error: "#c00",
errordark: "#900",
secondarylight: "#AAA",
secondary: "#888",
secondarydark: "#666",
warninglight: "#ff0",
warning: "#cc0",
warningdark: "#990",
infolight: "#69f",
info: "#36c",
infodark: "#039",
welllight: "#444",
well: "#222",
white: "#fff",
black: "#000",
hp: "#dd3434",
money: "#ffd700",
hack: "#adff2f",
combat: "#faffdf",
cha: "#a671d1",
int: "#6495ed",
rep: "#faffdf",
disabled: "#66cfbc",
backgroundprimary: "#000",
backgroundsecondary: "#000",
button: "#333",
};
export const getPredefinedThemes = (): IMap<IPredefinedTheme> => ({
Default: {
colors: defaultTheme,
},
Monokai: {
description: "Monokai'ish",
colors: {
primarylight: "#FFF",
primary: "#F8F8F2",
primarydark: "#FAFAEB",
successlight: "#ADE146",
success: "#A6E22E",
successdark: "#98E104",
errorlight: "#FF69A0",
error: "#F92672",
errordark: "#D10F56",
secondarylight: "#AAA",
secondary: "#888",
secondarydark: "#666",
warninglight: "#E1D992",
warning: "#E6DB74",
warningdark: "#EDDD54",
infolight: "#92E1F1",
info: "#66D9EF",
infodark: "#31CDED",
welllight: "#444",
well: "#222",
white: "#fff",
black: "#000",
hp: "#F92672",
money: "#E6DB74",
hack: "#A6E22E",
combat: "#75715E",
cha: "#AE81FF",
int: "#66D9EF",
rep: "#E69F66",
disabled: "#66cfbc",
backgroundprimary: "#272822",
backgroundsecondary: "#1B1C18",
button: "#333",
},
},
Warmer: {
credit: "hexnaught",
description: "Warmer, softer theme",
reference: "https://discord.com/channels/415207508303544321/921991895230611466/921999581020028938",
colors: {
primarylight: "#EA9062",
primary: "#DD7B4A",
primarydark: "#D3591C",
successlight: "#6ACF6A",
success: "#43BF43",
successdark: "#3E913E",
errorlight: "#C15757",
error: "#B34141",
errordark: "#752525",
secondarylight: "#AAA",
secondary: "#888",
secondarydark: "#666",
warninglight: "#E6E69D",
warning: "#DADA56",
warningdark: "#A1A106",
infolight: "#69f",
info: "#36c",
infodark: "#039",
welllight: "#444",
well: "#222",
white: "#fff",
black: "#000",
hp: "#dd3434",
money: "#ffd700",
hack: "#adff2f",
combat: "#faffdf",
cha: "#AD84CF",
int: "#6495ed",
rep: "#faffdf",
disabled: "#76C6B7",
backgroundprimary: "#000",
backgroundsecondary: "#000",
button: "#333",
},
},
"Dark+": {
credit: "LoganMD",
description: "VSCode Dark+",
reference: "https://discord.com/channels/415207508303544321/921991895230611466/921999975867617310",
colors: {
primarylight: "#E0E0BC",
primary: "#CCCCAE",
primarydark: "#B8B89C",
successlight: "#00F000",
success: "#00D200",
successdark: "#00B400",
errorlight: "#F00000",
error: "#C80000",
errordark: "#A00000",
secondarylight: "#B4AEAE",
secondary: "#969090",
secondarydark: "#787272",
warninglight: "#F0F000",
warning: "#C8C800",
warningdark: "#A0A000",
infolight: "#69f",
info: "#36c",
infodark: "#039",
welllight: "#444",
well: "#222",
white: "#fff",
black: "#1E1E1E",
hp: "#dd3434",
money: "#ffd700",
hack: "#adff2f",
combat: "#faffdf",
cha: "#a671d1",
int: "#6495ed",
rep: "#faffdf",
disabled: "#66cfbc",
backgroundprimary: "#1E1E1E",
backgroundsecondary: "#252525",
button: "#333",
},
},
"Mayukai Dark": {
credit: "Festive Noire",
description: "Mayukai Dark-esque",
reference: "https://discord.com/channels/415207508303544321/921991895230611466/922037502334889994",
colors: {
primarylight: "#DDDFC5",
primary: "#CDCFB6",
primarydark: "#9D9F8C",
successlight: "#00EF00",
success: "#00A500",
successdark: "#007A00",
errorlight: "#F92672",
error: "#CA1C5C",
errordark: "#90274A",
secondarylight: "#AAA",
secondary: "#888",
secondarydark: "#666",
warninglight: "#D3D300",
warning: "#cc0",
warningdark: "#990",
infolight: "#69f",
info: "#36c",
infodark: "#039",
welllight: "#444",
well: "#00010A",
white: "#fff",
black: "#020509",
hp: "#dd3434",
money: "#ffd700",
hack: "#8CCF27",
combat: "#faffdf",
cha: "#a671d1",
int: "#6495ed",
rep: "#faffdf",
disabled: "#66cfbc",
backgroundprimary: "#080C11",
backgroundsecondary: "#03080F",
button: "#00010A",
},
},
Purple: {
credit: "zer0ney",
description: "Essentially all defaults except for purple replacing the main colors",
reference: "https://discord.com/channels/415207508303544321/921991895230611466/922091815849570395",
colors: {
primarylight: "#BA55D3",
primary: "#9370DB",
primarydark: "#8A2BE2",
successlight: "#BA55D3",
success: "#9370DB",
successdark: "#8A2BE2",
errorlight: "#f00",
error: "#c00",
errordark: "#900",
secondarylight: "#AAA",
secondary: "#888",
secondarydark: "#666",
warninglight: "#ff0",
warning: "#cc0",
warningdark: "#990",
infolight: "#69f",
info: "#36c",
infodark: "#039",
welllight: "#444",
well: "#222",
white: "#fff",
black: "#000",
hp: "#dd3434",
money: "#ffd700",
hack: "#adff2f",
combat: "#faffdf",
cha: "#a671d1",
int: "#6495ed",
rep: "#faffdf",
disabled: "#66cfbc",
backgroundprimary: "#000",
backgroundsecondary: "#000",
button: "#333",
},
},
"Smooth Green": {
credit: "Swidt",
description: "A nice green theme that doesn't hurt your eyes.",
reference: "https://discord.com/channels/415207508303544321/921991895230611466/922243957986033725",
colors: {
primarylight: "#E0E0BC",
primary: "#B0D9A3",
primarydark: "#B8B89C",
successlight: "#00F000",
success: "#6BC16B",
successdark: "#00B400",
errorlight: "#F00000",
error: "#3D713D",
errordark: "#A00000",
secondarylight: "#B4AEAE",
secondary: "#8FAF85",
secondarydark: "#787272",
warninglight: "#F0F000",
warning: "#38F100",
warningdark: "#A0A000",
infolight: "#69f",
info: "#36c",
infodark: "#039",
welllight: "#444",
well: "#2F3C2B",
white: "#fff",
black: "#1E1E1E",
hp: "#dd3434",
money: "#4AA52E",
hack: "#adff2f",
combat: "#faffdf",
cha: "#a671d1",
int: "#6495ed",
rep: "#35A135",
disabled: "#66cfbc",
backgroundprimary: "#1E1E1E",
backgroundsecondary: "#252525",
button: "#2F3C2B",
},
},
Dracula: {
credit: "H3draut3r",
reference: "https://discord.com/channels/415207508303544321/921991895230611466/922296307836678144",
colors: {
primarylight: "#7082B8",
primary: "#F8F8F2",
primarydark: "#FF79C6",
successlight: "#0f0",
success: "#0c0",
successdark: "#090",
errorlight: "#FD4545",
error: "#FF2D2D",
errordark: "#C62424",
secondarylight: "#AAA",
secondary: "#8BE9FD",
secondarydark: "#666",
warninglight: "#FFC281",
warning: "#FFB86C",
warningdark: "#E6A055",
infolight: "#A0A0FF",
info: "#7070FF",
infodark: "#4040FF",
welllight: "#44475A",
well: "#363948",
white: "#fff",
black: "#282A36",
hp: "#D34448",
money: "#50FA7B",
hack: "#F1FA8C",
combat: "#BD93F9",
cha: "#FF79C6",
int: "#6495ed",
rep: "#faffdf",
disabled: "#66cfbc",
backgroundprimary: "#282A36",
backgroundsecondary: "#21222C",
button: "#21222C",
},
},
"Dark Blue": {
credit: "Saynt_Garmo",
reference: "https://discord.com/channels/415207508303544321/921991895230611466/923084732718264340",
colors: {
primarylight: "#023DDE",
primary: "#4A41C8",
primarydark: "#005299",
successlight: "#00FF00",
success: "#D1DAD1",
successdark: "#BFCABF",
errorlight: "#f00",
error: "#c00",
errordark: "#900",
secondarylight: "#AAA",
secondary: "#888",
secondarydark: "#666",
warninglight: "#ff0",
warning: "#cc0",
warningdark: "#990",
infolight: "#69f",
info: "#36c",
infodark: "#039",
welllight: "#444",
well: "#040505",
white: "#fff",
black: "#000000",
hp: "#dd3434",
money: "#ffd700",
hack: "#adff2f",
combat: "#faffdf",
cha: "#a671d1",
int: "#6495ed",
rep: "#faffdf",
disabled: "#66cfbc",
backgroundprimary: "#091419",
backgroundsecondary: "#000000",
button: "#000000",
},
},
Discord: {
credit: "Thermite",
description: "Discord inspired theme",
reference: "https://discord.com/channels/415207508303544321/921991895230611466/924305252017143818",
colors: {
primarylight: "#7389DC",
primary: "#7389DC",
primarydark: "#5964F1",
successlight: "#00CC00",
success: "#20DF20",
successdark: "#0CB80C",
errorlight: "#EA5558",
error: "#EC4145",
errordark: "#E82528",
secondarylight: "#C3C3C3",
secondary: "#9C9C9C",
secondarydark: "#4E4E4E",
warninglight: "#ff0",
warning: "#cc0",
warningdark: "#990",
infolight: "#69f",
info: "#36c",
infodark: "#1C4FB3",
welllight: "#999999",
well: "#35383C",
white: "#FFFFFF",
black: "#202225",
hp: "#FF5656",
money: "#43FF43",
hack: "#FFAB3D",
combat: "#8A90FD",
cha: "#FF51D9",
int: "#6495ed",
rep: "#FFFF30",
disabled: "#474B51",
backgroundprimary: "#2F3136",
backgroundsecondary: "#35393E",
button: "#333",
},
},
"One Dark": {
credit: "Dexalt142",
reference: "https://discord.com/channels/415207508303544321/921991895230611466/924650660694208512",
colors: {
primarylight: "#98C379",
primary: "#98C379",
primarydark: "#98C379",
successlight: "#98C379",
success: "#98C379",
successdark: "#98C379",
errorlight: "#E06C75",
error: "#BE5046",
errordark: "#BE5046",
secondarylight: "#AAA",
secondary: "#888",
secondarydark: "#666",
warninglight: "#E5C07B",
warning: "#E5C07B",
warningdark: "#D19A66",
infolight: "#61AFEF",
info: "#61AFEF",
infodark: "#61AFEF",
welllight: "#4B5263",
well: "#282C34",
white: "#ABB2BF",
black: "#282C34",
hp: "#E06C75",
money: "#E5C07B",
hack: "#98C379",
combat: "#ABB2BF",
cha: "#C678DD",
int: "#61AFEF",
rep: "#ABB2BF",
disabled: "#56B6C2",
backgroundprimary: "#282C34",
backgroundsecondary: "#21252B",
button: "#4B5263",
},
},
"Muted Gold & Blue": {
credit: "Sloth",
reference: "https://discord.com/channels/415207508303544321/921991895230611466/924672660758208563",
colors: {
primarylight: "#E3B54A",
primary: "#CAA243",
primarydark: "#7E6937",
successlight: "#82FF82",
success: "#6FDA6F",
successdark: "#64C364",
errorlight: "#FD5555",
error: "#D84A4A",
errordark: "#AC3939",
secondarylight: "#D8D0B8",
secondary: "#B1AA95",
secondarydark: "#736E5E",
warninglight: "#ff0",
warning: "#cc0",
warningdark: "#990",
infolight: "#69f",
info: "#36c",
infodark: "#039",
welllight: "#444",
well: "#111111",
white: "#fff",
black: "#070300",
hp: "#dd3434",
money: "#ffd700",
hack: "#adff2f",
combat: "#faffdf",
cha: "#a671d1",
int: "#6495ed",
rep: "#faffdf",
disabled: "#66cfbc",
backgroundprimary: "#0A0A0E",
backgroundsecondary: "#0E0E10",
button: "#222222",
},
},
"Default Lite": {
credit: "NmuGmu",
description: "Less eye-straining default theme",
reference: "https://discord.com/channels/415207508303544321/921991895230611466/925263801564151888",
colors: {
primarylight: "#28CF28",
primary: "#21A821",
primarydark: "#177317",
successlight: "#1CFF1C",
success: "#16CA16",
successdark: "#0D910D",
errorlight: "#FF3B3B",
error: "#C32D2D",
errordark: "#8E2121",
secondarylight: "#B3B3B3",
secondary: "#838383",
secondarydark: "#676767",
warninglight: "#FFFF3A",
warning: "#C3C32A",
warningdark: "#8C8C1E",
infolight: "#64CBFF",
info: "#3399CC",
infodark: "#246D91",
welllight: "#404040",
well: "#1C1C1C",
white: "#C3C3C3",
black: "#0A0B0B",
hp: "#C62E2E",
money: "#D6BB27",
hack: "#ADFF2F",
combat: "#E8EDCD",
cha: "#8B5FAF",
int: "#537CC8",
rep: "#E8EDCD",
disabled: "#5AB5A5",
backgroundprimary: "#0C0D0E",
backgroundsecondary: "#121415",
button: "#252829",
},
},
Light: {
credit: "matt",
reference: "https://discord.com/channels/415207508303544321/921991895230611466/926114005456658432",
colors: {
primarylight: "#535353",
primary: "#1A1A1A",
primarydark: "#0d0d0d",
successlight: "#63c439",
success: "#428226",
successdark: "#2E5A1B",
errorlight: "#df7051",
error: "#C94824",
errordark: "#91341B",
secondarylight: "#b3b3b3",
secondary: "#9B9B9B",
secondarydark: "#7A7979",
warninglight: "#e8d464",
warning: "#C6AD20",
warningdark: "#9F8A16",
infolight: "#6299cf",
info: "#3778B7",
infodark: "#30689C",
welllight: "#f9f9f9",
well: "#eaeaea",
white: "#F7F7F7",
black: "#F7F7F7",
hp: "#BF5C41",
money: "#E1B121",
hack: "#47BC38",
combat: "#656262",
cha: "#A568AC",
int: "#889BCF",
rep: "#656262",
disabled: "#70B4BF",
backgroundprimary: "#F7F7F7",
backgroundsecondary: "#f9f9f9",
button: "#eaeaea",
},
},
});

@ -21,6 +21,7 @@ export const TerminalHelpText: string[] = [
" grow Spoof money in a servers bank account, increasing the amount available.",
" hack Hack the current machine",
" help [command] Display this help text, or the help text for a command",
" history [-c] Display the terminal history",
" home Connect to home computer",
" hostname Displays the hostname of the machine",
" kill [script/pid] [args...] Stops the specified script on the current server ",
@ -255,6 +256,12 @@ export const HelpTexts: IMap<string[]> = {
" help scan-analyze",
" ",
],
history: [
"Usage: history [-c]",
" ",
"Without arguments, displays the terminal command history. To clear the history, pass in the '-c' argument.",
" ",
],
home: [
"Usage: home", " ", "Connect to your home computer. This will work no matter what server you are currently connected to.", " ",
],

@ -48,6 +48,7 @@ import { free } from "./commands/free";
import { grow } from "./commands/grow";
import { hack } from "./commands/hack";
import { help } from "./commands/help";
import { history } from "./commands/history";
import { home } from "./commands/home";
import { hostname } from "./commands/hostname";
import { kill } from "./commands/kill";
@ -143,7 +144,7 @@ export class Terminal implements ITerminal {
startGrow(player: IPlayer): void {
const server = player.getCurrentServer();
if (server instanceof HacknetServer) {
this.error("Cannot hack this kind of server");
this.error("Cannot grow this kind of server");
return;
}
if (!(server instanceof Server)) throw new Error("server should be normal server");
@ -152,7 +153,7 @@ export class Terminal implements ITerminal {
startWeaken(player: IPlayer): void {
const server = player.getCurrentServer();
if (server instanceof HacknetServer) {
this.error("Cannot hack this kind of server");
this.error("Cannot weaken this kind of server");
return;
}
if (!(server instanceof Server)) throw new Error("server should be normal server");
@ -241,7 +242,7 @@ export class Terminal implements ITerminal {
if (cancelled) return;
if (server instanceof HacknetServer) {
this.error("Cannot hack this kind of server");
this.error("Cannot grow this kind of server");
return;
}
if (!(server instanceof Server)) throw new Error("server should be normal server");
@ -268,7 +269,7 @@ export class Terminal implements ITerminal {
if (cancelled) return;
if (server instanceof HacknetServer) {
this.error("Cannot hack this kind of server");
this.error("Cannot weaken this kind of server");
return;
}
if (!(server instanceof Server)) throw new Error("server should be normal server");
@ -576,6 +577,7 @@ export class Terminal implements ITerminal {
if (this.commandHistory.length > 50) {
this.commandHistory.splice(0, 1);
}
player.terminalCommandHistory = this.commandHistory;
}
this.commandHistoryIndex = this.commandHistory.length;
const allCommands = ParseCommands(commands);
@ -785,6 +787,7 @@ export class Terminal implements ITerminal {
grow: grow,
hack: hack,
help: help,
history: history,
home: home,
hostname: hostname,
kill: kill,

@ -6,22 +6,8 @@ import { isScriptFilename } from "../../Script/isScriptFilename";
import FileSaver from "file-saver";
import JSZip from "jszip";
export function download(
terminal: ITerminal,
router: IRouter,
player: IPlayer,
server: BaseServer,
args: (string | number | boolean)[],
): void {
try {
if (args.length !== 1) {
terminal.error("Incorrect usage of download command. Usage: download [script/text file]");
return;
}
const fn = args[0] + "";
// If the parameter starts with *, download all files that match the wildcard pattern
if (fn.startsWith("*")) {
const matchEnding = fn.length == 1 || fn === "*.*" ? null : fn.slice(1); // Treat *.* the same as *
export function exportScripts(pattern: string, server: BaseServer): void {
const matchEnding = pattern.length == 1 || pattern === "*.*" ? null : pattern.slice(1); // Treat *.* the same as *
const zip = new JSZip();
// Helper function to zip any file contents whose name matches the pattern
const zipFiles = (fileNames: string[], fileContents: string[]): void => {
@ -44,11 +30,34 @@ export function download(
server.textFiles.map((s) => s.fn),
server.textFiles.map((s) => s.text),
);
// Return an error if no files matched, rather than an empty zip folder
if (Object.keys(zip.files).length == 0) return terminal.error(`No files match the pattern ${fn}`);
const zipFn = `bitburner${isScriptFilename(fn) ? "Scripts" : fn === "*.txt" ? "Texts" : "Files"}.zip`;
if (Object.keys(zip.files).length == 0) throw new Error(`No files match the pattern ${pattern}`);
const zipFn = `bitburner${isScriptFilename(pattern) ? "Scripts" : pattern === "*.txt" ? "Texts" : "Files"}.zip`;
zip.generateAsync({ type: "blob" }).then((content: any) => FileSaver.saveAs(content, zipFn));
}
export function download(
terminal: ITerminal,
router: IRouter,
player: IPlayer,
server: BaseServer,
args: (string | number | boolean)[],
): void {
try {
if (args.length !== 1) {
terminal.error("Incorrect usage of download command. Usage: download [script/text file]");
return;
}
const fn = args[0] + "";
// If the parameter starts with *, download all files that match the wildcard pattern
if (fn.startsWith("*")) {
try {
exportScripts(fn, server);
return;
} catch (error: any) {
return terminal.error(error.message);
}
} else if (isScriptFilename(fn)) {
// Download a single script
const script = terminal.getScript(player, fn);

@ -0,0 +1,27 @@
import { ITerminal } from "../ITerminal";
import { IRouter } from "../../ui/Router";
import { IPlayer } from "../../PersonObjects/IPlayer";
import { BaseServer } from "../../Server/BaseServer";
export function history(
terminal: ITerminal,
router: IRouter,
player: IPlayer,
server: BaseServer,
args: (string | number | boolean)[],
): void {
if (args.length === 0) {
terminal.commandHistory.forEach((command, index) => {
terminal.print(`${index.toString().padStart(2)} ${command}`);
});
return;
}
const arg = args[0] + "";
if (arg === "-c" || arg === "--clear") {
player.terminalCommandHistory = [];
terminal.commandHistory = [];
terminal.commandHistoryIndex = 1;
} else {
terminal.error("Incorrect usage of history command. usage: history [-c]");
}
}

@ -52,6 +52,12 @@ export function TerminalInput({ terminal, router, player }: IProps): React.React
const [possibilities, setPossibilities] = useState<string[]>([]);
const classes = useStyles();
// If we have no data in the current terminal history, let's initialize it from the player save
if (terminal.commandHistory.length === 0 && player.terminalCommandHistory.length > 0) {
terminal.commandHistory = player.terminalCommandHistory;
terminal.commandHistoryIndex = terminal.commandHistory.length;
}
// Need to run after state updates, for example if we need to move cursor
// *after* we modify input
useEffect(() => {

18
src/Themes/README.md Normal file

@ -0,0 +1,18 @@
# Themes
Feel free to contribute a new theme by submitting a pull request to the game!
See [CONTRIBUTING.md](/doc/CONTRIBUTING.md) for details.
## How create a new theme
1. Duplicate one of the folders in `/src/Themes/data` and give it a new name (keep the hyphenated format)
2. Modify the data in the new `/src/Themes/data/new-folder/index.ts` file
3. Replace the screenshot.png with one of your theme
4. Add the import/export into the `/src/Themes/data/index.ts` file
The themes are ordered according to the export order in `index.ts`
## Other resources
There is an external script called `theme-browser` which may include more themes than those shown here. Head over the [bitpacker](https://github.com/davidsiems/bitpacker) repository for details.

56
src/Themes/Themes.ts Normal file

@ -0,0 +1,56 @@
import { IMap } from "../types";
import * as predefined from "./data";
export interface ITheme {
[key: string]: string | undefined;
primarylight: string;
primary: string;
primarydark: string;
successlight: string;
success: string;
successdark: string;
errorlight: string;
error: string;
errordark: string;
secondarylight: string;
secondary: string;
secondarydark: string;
warninglight: string;
warning: string;
warningdark: string;
infolight: string;
info: string;
infodark: string;
welllight: string;
well: string;
white: string;
black: string;
hp: string;
money: string;
hack: string;
combat: string;
cha: string;
int: string;
rep: string;
disabled: string;
backgroundprimary: string;
backgroundsecondary: string;
button: string;
}
export interface IPredefinedTheme {
colors: ITheme;
name: string;
credit: string;
screenshot: string;
description: string;
reference?: string;
}
export const defaultTheme: ITheme = {
...predefined.Default.colors,
};
export const getPredefinedThemes = (): IMap<IPredefinedTheme> => ({
...predefined,
});

@ -0,0 +1,45 @@
import { IPredefinedTheme } from "../../Themes";
import img1 from "./screenshot.png";
export const Theme: IPredefinedTheme = {
name: "Dark Blue",
description: "Very dark with a blue/purplelly primary",
credit: "Saynt_Garmo",
reference: "https://discord.com/channels/415207508303544321/921991895230611466/923084732718264340",
screenshot: img1,
colors: {
primarylight: "#023DDE",
primary: "#4A41C8",
primarydark: "#005299",
successlight: "#00FF00",
success: "#D1DAD1",
successdark: "#BFCABF",
errorlight: "#f00",
error: "#c00",
errordark: "#900",
secondarylight: "#AAA",
secondary: "#888",
secondarydark: "#666",
warninglight: "#ff0",
warning: "#cc0",
warningdark: "#990",
infolight: "#69f",
info: "#36c",
infodark: "#039",
welllight: "#444",
well: "#040505",
white: "#fff",
black: "#000000",
hp: "#dd3434",
money: "#ffd700",
hack: "#adff2f",
combat: "#faffdf",
cha: "#a671d1",
int: "#6495ed",
rep: "#faffdf",
disabled: "#66cfbc",
backgroundprimary: "#091419",
backgroundsecondary: "#000000",
button: "#000000",
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

@ -0,0 +1,45 @@
import { IPredefinedTheme } from "../../Themes";
import img1 from "./screenshot.png";
export const Theme: IPredefinedTheme = {
name: "Dark+",
credit: "LoganMD",
description: "VSCode Dark+",
reference: "https://discord.com/channels/415207508303544321/921991895230611466/921999975867617310",
screenshot: img1,
colors: {
primarylight: "#E0E0BC",
primary: "#CCCCAE",
primarydark: "#B8B89C",
successlight: "#00F000",
success: "#00D200",
successdark: "#00B400",
errorlight: "#F00000",
error: "#C80000",
errordark: "#A00000",
secondarylight: "#B4AEAE",
secondary: "#969090",
secondarydark: "#787272",
warninglight: "#F0F000",
warning: "#C8C800",
warningdark: "#A0A000",
infolight: "#69f",
info: "#36c",
infodark: "#039",
welllight: "#444",
well: "#222",
white: "#fff",
black: "#1E1E1E",
hp: "#dd3434",
money: "#ffd700",
hack: "#adff2f",
combat: "#faffdf",
cha: "#a671d1",
int: "#6495ed",
rep: "#faffdf",
disabled: "#66cfbc",
backgroundprimary: "#1E1E1E",
backgroundsecondary: "#252525",
button: "#333",
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

@ -0,0 +1,45 @@
import { IPredefinedTheme } from "../../Themes";
import img1 from "./screenshot.png";
export const Theme: IPredefinedTheme = {
name: "Default-lite",
description: "Less eye-straining default theme",
credit: "NmuGmu",
reference: "https://discord.com/channels/415207508303544321/921991895230611466/925263801564151888",
screenshot: img1,
colors: {
primarylight: "#28CF28",
primary: "#21A821",
primarydark: "#177317",
successlight: "#1CFF1C",
success: "#16CA16",
successdark: "#0D910D",
errorlight: "#FF3B3B",
error: "#C32D2D",
errordark: "#8E2121",
secondarylight: "#B3B3B3",
secondary: "#838383",
secondarydark: "#676767",
warninglight: "#FFFF3A",
warning: "#C3C32A",
warningdark: "#8C8C1E",
infolight: "#64CBFF",
info: "#3399CC",
infodark: "#246D91",
welllight: "#404040",
well: "#1C1C1C",
white: "#C3C3C3",
black: "#0A0B0B",
hp: "#C62E2E",
money: "#D6BB27",
hack: "#ADFF2F",
combat: "#E8EDCD",
cha: "#8B5FAF",
int: "#537CC8",
rep: "#E8EDCD",
disabled: "#5AB5A5",
backgroundprimary: "#0C0D0E",
backgroundsecondary: "#121415",
button: "#252829",
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

@ -0,0 +1,44 @@
import { IPredefinedTheme } from "../../Themes";
import img1 from "./screenshot.png";
export const Theme: IPredefinedTheme = {
name: 'Default',
description: 'Default game theme, most supported',
credit: 'hydroflame',
screenshot: img1,
colors: {
primarylight: "#0f0",
primary: "#0c0",
primarydark: "#090",
successlight: "#0f0",
success: "#0c0",
successdark: "#090",
errorlight: "#f00",
error: "#c00",
errordark: "#900",
secondarylight: "#AAA",
secondary: "#888",
secondarydark: "#666",
warninglight: "#ff0",
warning: "#cc0",
warningdark: "#990",
infolight: "#69f",
info: "#36c",
infodark: "#039",
welllight: "#444",
well: "#222",
white: "#fff",
black: "#000",
hp: "#dd3434",
money: "#ffd700",
hack: "#adff2f",
combat: "#faffdf",
cha: "#a671d1",
int: "#6495ed",
rep: "#faffdf",
disabled: "#66cfbc",
backgroundprimary: "#000",
backgroundsecondary: "#000",
button: "#333",
},
};

Some files were not shown because too many files have changed in this diff Show More