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/ node_modules/
dist/
input/
.dist .dist
.tmp .tmp
.package .package
.build
assets/
css/
.cypress/ .cypress/
cypress/
dist/
input/
assets/
doc/ doc/
markdown/ markdown/
netscript_tests/
scripts/
test/netscript/
electron/lib electron/lib
electron/greenworks.js electron/greenworks.js
src/ThirdParty/* src/ThirdParty/*
src/JSInterpreter.js src/JSInterpreter.js
tools/engines-check/
test/*.bundle.*
editor.main.js editor.main.js
main.bundle.js main.bundle.js
webpack.config.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 /doc/build
/node_modules /node_modules
/electron/node_modules /electron/node_modules
/dist/*.map
/test/*.map /test/*.map
/test/*.bundle.* /test/*.bundle.*
/test/*.css /test/*.css
/input/bitburner.api.json
.cypress .cypress
# tmp folder for electron # 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. 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/). 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 # 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 Anyone is welcome to contribute to the documentation by editing the [source
files](/doc/source) and then making a pull request with your contributions. files](/doc/source) and then making a pull request with your contributions.
For further guidance, please refer to the "As A Documentor" section of For further guidance, please refer to the "As A Documentor" section of
[CONTRIBUTING](CONTRIBUTING.md). [CONTRIBUTING](./doc/CONTRIBUTING.md).
# Contribution # Contribution
There are many ways to contribute to the game. It can be as simple as fixing 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, 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, 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 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", "baseUrl": "http://localhost:8000",
"fixturesFolder": false,
"trashAssetsBeforeRuns": true, "trashAssetsBeforeRuns": true,
"screenshotsFolder": ".cypress/screenshots",
"videosFolder": ".cypress/videos",
"videoUploadOnPasses": false, "videoUploadOnPasses": false,
"viewportWidth": 1980, "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 Docs](http://bitburner.readthedocs.io/), you will
need to have Python installed, along with [Sphinx](http://www.sphinx-doc.org). 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`. 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 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 | | 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 RAM of 8 | |
| | | * Total Hacknet Cores of 4 | | | | | * 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 | | | | * $15m | * New Tokyo |
| | | | * Ishima | | | | | * Ishima |
| | | | * Volhaven | | | | | * Volhaven |
@ -74,8 +90,19 @@ List of Factions and their Requirements
| | | | * New Tokyo | | | | | * New Tokyo |
| | | | * Ishima | | | | | * Ishima |
+---------------------+----------------+-----------------------------------------+-------------------------------+ +---------------------+----------------+-----------------------------------------+-------------------------------+
| Hacking | NiteSec | * Install a backdoor on the avmnite-02h | | .. raw:: html
| Groups | | server | |
</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 | | | | The Black Hand | * Install a backdoor on the I.I.I.I | |
@ -86,7 +113,18 @@ List of Factions and their Requirements
| | | server | | | | | 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 | | | | | the Corporation | |
+ +----------------+-----------------------------------------+-------------------------------+ + +----------------+-----------------------------------------+-------------------------------+
| | MegaCorp | * Have 200k reputation with | | | | MegaCorp | * Have 200k reputation with | |
@ -118,8 +156,19 @@ List of Factions and their Requirements
| | | * Install a backdoor on the | | | | | * Install a backdoor on the | |
| | | fulcrumassets server | | | | | fulcrumassets server | |
+---------------------+----------------+-----------------------------------------+-------------------------------+ +---------------------+----------------+-----------------------------------------+-------------------------------+
| Criminal | Slum Snakes | * All Combat Stats of 30 | | .. raw:: html
| Organizations | | * -9 Karma | |
</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 | | | | | * $1m | |
+ +----------------+-----------------------------------------+-------------------------------+ + +----------------+-----------------------------------------+-------------------------------+
| | Tetrads | * Be in Chongqing, New Tokyo, or Ishima | | | | Tetrads | * Be in Chongqing, New Tokyo, or Ishima | |
@ -150,8 +199,19 @@ List of Factions and their Requirements
| | | * -90 Karma | | | | | * -90 Karma | |
| | | * Not working for CIA or NSA | | | | | * Not working for CIA or NSA | |
+---------------------+----------------+-----------------------------------------+-------------------------------+ +---------------------+----------------+-----------------------------------------+-------------------------------+
| Endgame | The Covenant | * 20 Augmentations | | .. raw:: html
| Factions | | * $75b | |
</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 | | | | | * Hacking Level of 850 | |
| | | * All Combat Stats of 850 | | | | | * All Combat Stats of 850 | |
+ +----------------+-----------------------------------------+-------------------------------+ + +----------------+-----------------------------------------+-------------------------------+
@ -165,3 +225,6 @@ List of Factions and their Requirements
| | | * Hacking Level of 1500 | | | | | * Hacking Level of 1500 | |
| | | * All Combat Stats of 1200 | | | | | * 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 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 // This is backward but the game fills in an array called `document.achievements` and we retrieve it from
// here. Hey if it works it works. // here. Hey if it works it works.
const steamAchievements = greenworks.getAchievementNames(); const steamAchievements = greenworks.getAchievementNames();
log.debug(`All Steam achievements ${JSON.stringify(steamAchievements)}`); log.silly(`All Steam achievements ${JSON.stringify(steamAchievements)}`);
const playerAchieved = (await Promise.all(steamAchievements.map(checkSteamAchievement))).filter(name => !!name); const playerAchieved = (await Promise.all(steamAchievements.map(checkSteamAchievement))).filter(name => !!name);
log.debug(`Player has Steam achievements ${JSON.stringify(playerAchieved)}`); log.debug(`Player has Steam achievements ${JSON.stringify(playerAchieved)}`);
const intervalID = setInterval(async () => { const intervalID = setInterval(async () => {

@ -12,11 +12,13 @@ async function initialize(win) {
window = win; window = win;
server = http.createServer(async function (req, res) { server = http.createServer(async function (req, res) {
let body = ""; let body = "";
res.setHeader('Content-Type', 'application/json');
req.on("data", (chunk) => { req.on("data", (chunk) => {
body += chunk.toString(); // convert Buffer to string body += chunk.toString(); // convert Buffer to string
}); });
req.on("end", () => {
req.on("end", async () => {
const providedToken = req.headers?.authorization?.replace('Bearer ', '') ?? ''; const providedToken = req.headers?.authorization?.replace('Bearer ', '') ?? '';
const isValid = providedToken === getAuthenticationToken(); const isValid = providedToken === getAuthenticationToken();
if (isValid) { if (isValid) {
@ -24,8 +26,11 @@ async function initialize(win) {
} else { } else {
log.log('Invalid authentication token'); log.log('Invalid authentication token');
res.writeHead(401); res.writeHead(401);
res.write('Invalid authentication token');
res.end(); res.end(JSON.stringify({
success: false,
msg: 'Invalid authentication token'
}));
return; return;
} }
@ -35,17 +40,56 @@ async function initialize(win) {
} catch (error) { } catch (error) {
log.warn(`Invalid body data`); log.warn(`Invalid body data`);
res.writeHead(400); res.writeHead(400);
res.write('Invalid body data'); res.end(JSON.stringify({
res.end(); success: false,
msg: 'Invalid body data'
}));
return; return;
} }
if (data) { let result;
window.webContents.executeJavaScript(`document.saveFile("${data.filename}", "${data.code}")`).then((result) => { switch(req.method) {
res.write(result); // Request files
res.end(); 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 cp = require("child_process");
const path = require("path"); const path = require("path");
const fs = require("fs"); const fs = require("fs");
const { windowTracker } = require("./windowTracker");
const { fileURLToPath } = require("url"); const { fileURLToPath } = require("url");
const debug = process.argv.includes("--debug"); const debug = process.argv.includes("--debug");
async function createWindow(killall) { async function createWindow(killall) {
const setStopProcessHandler = global.app_handlers.stopProcess; 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({ const window = new BrowserWindow({
icon,
show: false, show: false,
backgroundThrottling: false, backgroundThrottling: false,
backgroundColor: "#000000", 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.removeMenu();
window.maximize();
noScripts = killall ? { query: { noScripts: killall } } : {}; noScripts = killall ? { query: { noScripts: killall } } : {};
window.loadFile("index.html", noScripts); window.loadFile("index.html", noScripts);
window.show(); window.show();

@ -1,12 +1,19 @@
/* eslint-disable no-process-exit */ /* eslint-disable no-process-exit */
/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-var-requires */
const { app, dialog, BrowserWindow } = require("electron"); const { app, dialog, BrowserWindow, ipcMain } = require("electron");
const log = require("electron-log"); const log = require("electron-log");
const greenworks = require("./greenworks"); const greenworks = require("./greenworks");
const api = require("./api-server"); const api = require("./api-server");
const gameWindow = require("./gameWindow"); const gameWindow = require("./gameWindow");
const achievements = require("./achievements"); const achievements = require("./achievements");
const utils = require("./utils"); const utils = require("./utils");
const storage = require("./storage");
const debounce = require("lodash/debounce");
const Config = require("electron-config");
const config = new Config();
log.transports.file.level = config.get("file-log-level", "info");
log.transports.console.level = config.get("console-log-level", "debug");
log.catchErrors(); log.catchErrors();
log.info(`Started app: ${JSON.stringify(process.argv)}`); log.info(`Started app: ${JSON.stringify(process.argv)}`);
@ -30,6 +37,8 @@ try {
global.greenworksError = ex.message; global.greenworksError = ex.message;
} }
let isRestoreDisabled = false;
function setStopProcessHandler(app, window, enabled) { function setStopProcessHandler(app, window, enabled) {
const closingWindowHandler = async (e) => { const closingWindowHandler = async (e) => {
// We need to prevent the default closing event to add custom logic // We need to prevent the default closing event to add custom logic
@ -41,6 +50,18 @@ function setStopProcessHandler(app, window, enabled) {
// Shutdown the http server // Shutdown the http server
api.disable(); api.disable();
// Trigger debounced saves right now before closing
try {
await saveToDisk.flush();
} catch (error) {
log.error(error);
}
try {
await saveToCloud.flush();
} catch (error) {
log.error(error);
}
// Because of a steam limitation, if the player has launched an external browser, // Because of a steam limitation, if the player has launched an external browser,
// steam will keep displaying the game as "Running" in their UI as long as the browser is up. // steam will keep displaying the game as "Running" in their UI as long as the browser is up.
// So we'll alert the player to close their browser. // So we'll alert the player to close their browser.
@ -87,21 +108,106 @@ function setStopProcessHandler(app, window, enabled) {
process.exit(0); process.exit(0);
}; };
const receivedGameReadyHandler = async (event, arg) => {
if (!window) {
log.warn("Window was undefined in game info handler");
return;
}
log.debug("Received game information", arg);
window.gameInfo = { ...arg };
await storage.prepareSaveFolders(window);
const restoreNewest = config.get("onload-restore-newest", true);
if (restoreNewest && !isRestoreDisabled) {
try {
await storage.restoreIfNewerExists(window)
} catch (error) {
log.error("Could not restore newer file", error);
}
}
}
const receivedDisableRestoreHandler = async (event, arg) => {
if (!window) {
log.warn("Window was undefined in disable import handler");
return;
}
log.debug(`Disabling auto-restore for ${arg.duration}ms.`);
isRestoreDisabled = true;
setTimeout(() => {
isRestoreDisabled = false;
log.debug("Re-enabling auto-restore");
}, arg.duration);
}
const receivedGameSavedHandler = async (event, arg) => {
if (!window) {
log.warn("Window was undefined in game saved handler");
return;
}
const { save, ...other } = arg;
log.silly("Received game saved info", {...other, save: `${save.length} bytes`});
if (storage.isAutosaveEnabled()) {
saveToDisk(save, arg.fileName);
}
if (storage.isCloudEnabled()) {
const minimumPlaytime = 1000 * 60 * 15;
const playtime = window.gameInfo.player.playtime;
log.silly(window.gameInfo);
if (playtime > minimumPlaytime) {
saveToCloud(save);
} else {
log.debug(`Auto-save to cloud disabled for save game under ${minimumPlaytime}ms (${playtime}ms)`);
}
}
}
const saveToCloud = debounce(async (save) => {
log.debug("Saving to Steam Cloud ...")
try {
const playerId = window.gameInfo.player.identifier;
await storage.pushGameSaveToSteamCloud(save, playerId);
log.silly("Saved Game to Steam Cloud");
} catch (error) {
log.error(error);
utils.writeToast(window, "Could not save to Steam Cloud.", "error", 5000);
}
}, config.get("cloud-save-min-time", 1000 * 60 * 15), { leading: true });
const saveToDisk = debounce(async (save, fileName) => {
log.debug("Saving to Disk ...")
try {
const file = await storage.saveGameToDisk(window, { save, fileName });
log.silly(`Saved Game to '${file.replaceAll('\\', '\\\\')}'`);
} catch (error) {
log.error(error);
utils.writeToast(window, "Could not save to disk", "error", 5000);
}
}, config.get("disk-save-min-time", 1000 * 60 * 5), { leading: true });
if (enabled) { if (enabled) {
log.debug('Adding closing handlers'); log.debug("Adding closing handlers");
ipcMain.on("push-game-ready", receivedGameReadyHandler);
ipcMain.on("push-game-saved", receivedGameSavedHandler);
ipcMain.on("push-disable-restore", receivedDisableRestoreHandler)
window.on("closed", clearWindowHandler); window.on("closed", clearWindowHandler);
window.on("close", closingWindowHandler) window.on("close", closingWindowHandler)
app.on("window-all-closed", stopProcessHandler); app.on("window-all-closed", stopProcessHandler);
} else { } else {
log.debug('Removing closing handlers'); log.debug("Removing closing handlers");
ipcMain.removeAllListeners();
window.removeListener("closed", clearWindowHandler); window.removeListener("closed", clearWindowHandler);
window.removeListener("close", closingWindowHandler); window.removeListener("close", closingWindowHandler);
app.removeListener("window-all-closed", stopProcessHandler); app.removeListener("window-all-closed", stopProcessHandler);
} }
} }
function startWindow(noScript) { async function startWindow(noScript) {
gameWindow.createWindow(noScript); return gameWindow.createWindow(noScript);
} }
global.app_handlers = { global.app_handlers = {
@ -110,7 +216,7 @@ global.app_handlers = {
} }
app.whenReady().then(async () => { app.whenReady().then(async () => {
log.info('Application is ready!'); log.info("Application is ready!");
if (process.argv.includes("--export-save")) { if (process.argv.includes("--export-save")) {
const window = new BrowserWindow({ show: false }); const window = new BrowserWindow({ show: false });
@ -119,15 +225,14 @@ app.whenReady().then(async () => {
setStopProcessHandler(app, window, true); setStopProcessHandler(app, window, true);
await utils.exportSave(window); await utils.exportSave(window);
} else { } else {
startWindow(process.argv.includes("--no-scripts")); const window = await startWindow(process.argv.includes("--no-scripts"));
} if (global.greenworksError) {
await dialog.showMessageBox(window, {
if (global.greenworksError) { title: "Bitburner",
dialog.showMessageBox({ message: "Could not connect to Steam",
title: 'Bitburner', detail: `${global.greenworksError}\n\nYou won't be able to receive achievements until this is resolved and you restart the game.`,
message: 'Could not connect to Steam', type: 'warning', buttons: ['OK']
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 */ /* eslint-disable @typescript-eslint/no-var-requires */
const { Menu, clipboard, dialog } = require("electron"); const { app, Menu, clipboard, dialog, shell } = require("electron");
const log = require("electron-log"); const log = require("electron-log");
const Config = require("electron-config");
const api = require("./api-server"); const api = require("./api-server");
const utils = require("./utils"); const utils = require("./utils");
const storage = require("./storage");
const config = new Config();
function getMenu(window) { function getMenu(window) {
return Menu.buildFromTemplate([ return Menu.buildFromTemplate([
{
label: "File",
submenu: [
{
label: "Save Game",
click: () => window.webContents.send("trigger-save"),
},
{
label: "Export Save",
click: () => window.webContents.send("trigger-game-export"),
},
{
label: "Export Scripts",
click: async () => window.webContents.send("trigger-scripts-export"),
},
{
type: "separator",
},
{
label: "Load Last Save",
click: async () => {
try {
const saveGame = await storage.loadLastFromDisk(window);
window.webContents.send("push-save-request", { save: saveGame });
} catch (error) {
log.error(error);
utils.writeToast(window, "Could not load last save from disk", "error", 5000);
}
}
},
{
label: "Load From File",
click: async () => {
const defaultPath = await storage.getSaveFolder(window);
const result = await dialog.showOpenDialog(window, {
title: "Load From File",
defaultPath: defaultPath,
buttonLabel: "Load",
filters: [
{ name: "Game Saves", extensions: ["json", "json.gz", "txt"] },
{ name: "All", extensions: ["*"] },
],
properties: [
"openFile", "dontAddToRecent",
]
});
if (result.canceled) return;
const file = result.filePaths[0];
try {
const saveGame = await storage.loadFileFromDisk(file);
window.webContents.send("push-save-request", { save: saveGame });
} catch (error) {
log.error(error);
utils.writeToast(window, "Could not load save from disk", "error", 5000);
}
}
},
{
label: "Load From Steam Cloud",
enabled: storage.isCloudEnabled(),
click: async () => {
try {
const saveGame = await storage.getSteamCloudSaveString();
await storage.pushSaveGameForImport(window, saveGame, false);
} catch (error) {
log.error(error);
utils.writeToast(window, "Could not load from Steam Cloud", "error", 5000);
}
}
},
{
type: "separator",
},
{
label: "Compress Disk Saves (.gz)",
type: "checkbox",
checked: storage.isSaveCompressionEnabled(),
click: (menuItem) => {
storage.setSaveCompressionConfig(menuItem.checked);
utils.writeToast(window,
`${menuItem.checked ? "Enabled" : "Disabled"} Save Compression`, "info", 5000);
refreshMenu(window);
},
},
{
label: "Auto-Save to Disk",
type: "checkbox",
checked: storage.isAutosaveEnabled(),
click: (menuItem) => {
storage.setAutosaveConfig(menuItem.checked);
utils.writeToast(window,
`${menuItem.checked ? "Enabled" : "Disabled"} Auto-Save to Disk`, "info", 5000);
refreshMenu(window);
},
},
{
label: "Auto-Save to Steam Cloud",
type: "checkbox",
enabled: !global.greenworksError,
checked: storage.isCloudEnabled(),
click: (menuItem) => {
storage.setCloudEnabledConfig(menuItem.checked);
utils.writeToast(window,
`${menuItem.checked ? "Enabled" : "Disabled"} Auto-Save to Steam Cloud`, "info", 5000);
refreshMenu(window);
},
},
{
label: "Restore Newest on Load",
type: "checkbox",
checked: config.get("onload-restore-newest", true),
click: (menuItem) => {
config.set("onload-restore-newest", menuItem.checked);
utils.writeToast(window,
`${menuItem.checked ? "Enabled" : "Disabled"} Restore Newest on Load`, "info", 5000);
refreshMenu(window);
},
},
{
type: "separator",
},
{
label: "Open Directory",
submenu: [
{
label: "Open Game Directory",
click: () => shell.openPath(app.getAppPath()),
},
{
label: "Open Saves Directory",
click: async () => {
const path = await storage.getSaveFolder(window);
shell.openPath(path);
},
},
{
label: "Open Logs Directory",
click: () => shell.openPath(app.getPath("logs")),
},
{
label: "Open Data Directory",
click: () => shell.openPath(app.getPath("userData")),
},
]
},
{
type: "separator",
},
{
label: "Quit",
click: () => app.quit(),
},
]
},
{ {
label: "Edit", label: "Edit",
submenu: [ submenu: [
@ -163,6 +321,17 @@ function getMenu(window) {
label: "Activate", label: "Activate",
click: () => window.webContents.openDevTools(), click: () => window.webContents.openDevTools(),
}, },
{
label: "Delete Steam Cloud Data",
enabled: !global.greenworksError,
click: async () => {
try {
await storage.deleteCloudFile();
} catch (error) {
log.error(error);
}
}
}
], ],
}, },
]); ]);

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

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

38
electron/preload.js Normal file

@ -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> <link rel="shortcut icon" href="favicon.ico"></head>
<body> <body>
<div id="root"/> <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> </html>

File diff suppressed because it is too large Load Diff

@ -8,4 +8,8 @@ module.exports = {
'.cypress', 'node_modules', 'dist', '.cypress', 'node_modules', 'dist',
], ],
testEnvironment: "jsdom", 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/react": "^11.4.1",
"@emotion/styled": "^11.3.0", "@emotion/styled": "^11.3.0",
"@material-ui/core": "^4.12.3", "@material-ui/core": "^4.12.3",
"@microsoft/api-documenter": "^7.13.65",
"@microsoft/api-extractor": "^7.18.17",
"@monaco-editor/react": "^4.2.2", "@monaco-editor/react": "^4.2.2",
"@mui/icons-material": "^5.0.3", "@mui/icons-material": "^5.0.3",
"@mui/material": "^5.0.3", "@mui/material": "^5.0.3",
"@mui/styles": "^5.0.1", "@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": "^8.4.1",
"acorn-walk": "^8.1.1", "acorn-walk": "^8.1.1",
"arg": "^5.0.0", "arg": "^5.0.0",
@ -45,7 +36,6 @@
"notistack": "^2.0.2", "notistack": "^2.0.2",
"numeral": "2.0.6", "numeral": "2.0.6",
"prop-types": "^15.8.0", "prop-types": "^15.8.0",
"raw-loader": "^4.0.2",
"react": "^17.0.2", "react": "^17.0.2",
"react-beautiful-dnd": "^13.1.0", "react-beautiful-dnd": "^13.1.0",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
@ -59,10 +49,19 @@
"@babel/preset-env": "^7.15.0", "@babel/preset-env": "^7.15.0",
"@babel/preset-react": "^7.0.0", "@babel/preset-react": "^7.0.0",
"@babel/preset-typescript": "^7.15.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", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.1",
"@testing-library/cypress": "^8.0.1", "@testing-library/cypress": "^8.0.1",
"@types/acorn": "^4.0.6",
"@types/escodegen": "^0.0.7",
"@types/file-saver": "^2.0.3", "@types/file-saver": "^2.0.3",
"@types/lodash": "^4.14.168", "@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/eslint-plugin": "^4.22.0",
"@typescript-eslint/parser": "^4.22.0", "@typescript-eslint/parser": "^4.22.0",
"babel-jest": "^27.0.6", "babel-jest": "^27.0.6",
@ -71,6 +70,7 @@
"electron": "^14.0.2", "electron": "^14.0.2",
"electron-packager": "^15.4.0", "electron-packager": "^15.4.0",
"eslint": "^7.24.0", "eslint": "^7.24.0",
"file-loader": "^6.2.0",
"fork-ts-checker-webpack-plugin": "^6.3.3", "fork-ts-checker-webpack-plugin": "^6.3.3",
"html-webpack-plugin": "^3.2.0", "html-webpack-plugin": "^3.2.0",
"http-server": "^13.0.1", "http-server": "^13.0.1",
@ -79,6 +79,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mini-css-extract-plugin": "^0.4.1", "mini-css-extract-plugin": "^0.4.1",
"prettier": "^2.3.2", "prettier": "^2.3.2",
"raw-loader": "^4.0.2",
"react-refresh": "^0.10.0", "react-refresh": "^0.10.0",
"regenerator-runtime": "^0.13.9", "regenerator-runtime": "^0.13.9",
"source-map": "^0.7.3", "source-map": "^0.7.3",
@ -102,7 +103,7 @@
"cy:dev": "start-server-and-test start:dev http://localhost:8000 cy:open", "cy:dev": "start-server-and-test start:dev http://localhost:8000 cy:open",
"cy:open": "cypress open", "cy:open": "cypress open",
"cy:run": "cypress run", "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 .", "format": "prettier --write .",
"start": "http-server -p 8000", "start": "http-server -p 8000",
"start:dev": "webpack-dev-server --progress --env.devServer --mode development", "start:dev": "webpack-dev-server --progress --env.devServer --mode development",
@ -112,13 +113,17 @@
"build:dev": "webpack --mode development", "build:dev": "webpack --mode development",
"lint": "eslint --fix . --ext js,jsx,ts,tsx", "lint": "eslint --fix . --ext js,jsx,ts,tsx",
"lint:report": "eslint --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": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"watch": "webpack --watch --mode production", "watch": "webpack --watch --mode production",
"watch:dev": "webpack --watch --mode development", "watch:dev": "webpack --watch --mode development",
"electron": "sh ./package.sh", "electron": "sh ./package.sh",
"electron:packager": "electron-packager .package bitburner --all --out .build --overwrite --icon .package/icon.png --no-prune", "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" "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 #!/bin/sh
mkdir -p .package/dist/src/ThirdParty || true # Clear out any files remaining from old builds
mkdir -p .package/src/ThirdParty || true rm -rf .package
mkdir -p .package/node_modules || true
cp index.html .package mkdir -p .package/dist/
cp -r electron/* .package cp -r electron/* .package
cp -r dist/ext .package/dist cp -r dist .package
cp -r dist/icons .package/dist cp index.html .package/index.html
# 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
# Install electron sub-dependencies # Install electron sub-dependencies
cd electron cd electron
npm install npm install
cd .. cd ..
BUILD_PLATFORM="${1:-"all"}"
# And finally build the app. # 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 // Defined by webpack on startup or compilation
declare let __COMMIT_HASH__: string; 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: info:
"A brain implant carefully assembled around the synapses, which " + "A brain implant carefully assembled around the synapses, which " +
"micromanages the activity and levels of various neuroreceptor " + "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.", "allowing the user to multitask much more effectively.",
stats: ( stats: (
<> <>

@ -26,7 +26,7 @@ export function InstalledAugmentations(): React.ReactElement {
if (Settings.OwnedAugmentationsOrder === OwnedAugmentationsOrderSetting.Alphabetically) { if (Settings.OwnedAugmentationsOrder === OwnedAugmentationsOrderSetting.Alphabetically) {
sourceAugs.sort((aug1, aug2) => { 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> </Typography>
} }
> >
<span onClick={() => setPortalOpen(true)} className={cssClass}> <span onClick={() => setPortalOpen(true)} className={cssClass} aria-label={`enter-bitnode-${bitNode.number.toString()}`}>
<b>O</b> <b>O</b>
</span> </span>
</Tooltip> </Tooltip>

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

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

@ -159,18 +159,18 @@ export function SlotMachine(props: IProps): React.ReactElement {
const copy = index.slice(); const copy = index.slice();
for (let i = 0; i < copy.length; i++) { for (let i = 0; i < copy.length; i++) {
if (copy[i] === locks[i] && !stoppedOne) continue; 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; stoppedOne = true;
} }
setIndex(copy); setIndex(copy);
if (stoppedOne && copy.every((e, i) => e === locks[i])) { 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 [ return [
[ [
symbols[(index[0] + symbols.length - 1) % symbols.length], symbols[(index[0] + symbols.length - 1) % symbols.length],
@ -209,8 +209,7 @@ export function SlotMachine(props: IProps): React.ReactElement {
]); ]);
} }
function checkWinnings(): void { function checkWinnings(t:string[][]): void {
const t = getTable();
const getPaylineData = function (payline: number[][]): string[] { const getPaylineData = function (payline: number[][]): string[] {
const data = []; const data = [];
for (const point of payline) { for (const point of payline) {
@ -267,7 +266,7 @@ export function SlotMachine(props: IProps): React.ReactElement {
setInvestment(investment); setInvestment(investment);
} }
const t = getTable(); const t = getTable(index, symbols);
// prettier-ignore // prettier-ignore
return ( return (
<> <>
@ -288,7 +287,7 @@ export function SlotMachine(props: IProps): React.ReactElement {
disabled={!canPlay} disabled={!canPlay}
>Spin!</Button>)}} >Spin!</Button>)}}
/> />
<Typography variant="h4">{status}</Typography> <Typography variant="h4">{status}</Typography>
<Typography>Pay lines</Typography> <Typography>Pay lines</Typography>

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

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

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

@ -89,19 +89,21 @@ export function Overview({ rerender }: IProps): React.ReactElement {
<StatsTable rows={multRows} /> <StatsTable rows={multRows} />
<br /> <br />
<BonusTime /> <BonusTime />
<Tooltip <Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', width: 'fit-content' }}>
title={ <Tooltip
<Typography> title={
Get a copy of and read 'The Complete Handbook for Creating a Successful Corporation.' This is a .lit file <Typography>
that guides you through the beginning of setting up a Corporation and provides some tips/pointers for Get a copy of and read 'The Complete Handbook for Creating a Successful Corporation.' This is a .lit file
helping you get started with managing it. that guides you through the beginning of setting up a Corporation and provides some tips/pointers for
</Typography> helping you get started with managing it.
} </Typography>
> }
<Button onClick={() => corp.getStarterGuide(player)}>Getting Started Guide</Button> >
</Tooltip> <Button onClick={() => corp.getStarterGuide(player)}>Getting Started Guide</Button>
{corp.public ? <PublicButtons rerender={rerender} /> : <PrivateButtons rerender={rerender} />} </Tooltip>
<BribeButton /> {corp.public ? <PublicButtons rerender={rerender} /> : <PrivateButtons rerender={rerender} />}
<BribeButton />
</Box>
<br /> <br />
<Upgrades rerender={rerender} /> <Upgrades rerender={rerender} />
</> </>
@ -125,11 +127,9 @@ function PrivateButtons({ rerender }: IPrivateButtonsProps): React.ReactElement
return ( return (
<> <>
<Tooltip title={<Typography>{findInvestorsTooltip}</Typography>}> <Tooltip title={<Typography>{findInvestorsTooltip}</Typography>}>
<span> <Button disabled={!fundingAvailable} onClick={() => setFindInvestorsopen(true)}>
<Button disabled={!fundingAvailable} onClick={() => setFindInvestorsopen(true)}> Find Investors
Find Investors </Button>
</Button>
</span>
</Tooltip> </Tooltip>
<Tooltip <Tooltip
title={ title={
@ -143,7 +143,6 @@ function PrivateButtons({ rerender }: IPrivateButtonsProps): React.ReactElement
</Tooltip> </Tooltip>
<FindInvestorsModal open={findInvestorsopen} onClose={() => setFindInvestorsopen(false)} rerender={rerender} /> <FindInvestorsModal open={findInvestorsopen} onClose={() => setFindInvestorsopen(false)} rerender={rerender} />
<GoPublicModal open={goPublicopen} onClose={() => setGoPublicopen(false)} rerender={rerender} /> <GoPublicModal open={goPublicopen} onClose={() => setGoPublicopen(false)} rerender={rerender} />
<br />
</> </>
); );
} }
@ -201,8 +200,8 @@ function PublicButtons({ rerender }: IPublicButtonsProps): React.ReactElement {
const sellSharesTooltip = sellSharesOnCd const sellSharesTooltip = sellSharesOnCd
? "Cannot sell shares for " + corp.convertCooldownToString(corp.shareSaleCooldown) ? "Cannot sell shares for " + corp.convertCooldownToString(corp.shareSaleCooldown)
: "Sell your shares in the company. The money earned from selling your " + : "Sell your shares in the company. The money earned from selling your " +
"shares goes into your personal account, not the Corporation's. " + "shares goes into your personal account, not the Corporation's. " +
"This is one of the only ways to profit from your business venture."; "This is one of the only ways to profit from your business venture.";
const issueNewSharesOnCd = corp.issueNewSharesCooldown > 0; const issueNewSharesOnCd = corp.issueNewSharesCooldown > 0;
const issueNewSharesTooltip = issueNewSharesOnCd const issueNewSharesTooltip = issueNewSharesOnCd
@ -212,28 +211,21 @@ function PublicButtons({ rerender }: IPublicButtonsProps): React.ReactElement {
return ( return (
<> <>
<Tooltip title={<Typography>{sellSharesTooltip}</Typography>}> <Tooltip title={<Typography>{sellSharesTooltip}</Typography>}>
<span> <Button disabled={sellSharesOnCd} onClick={() => setSellSharesOpen(true)}>
<Button disabled={sellSharesOnCd} onClick={() => setSellSharesOpen(true)}> Sell Shares
Sell Shares </Button>
</Button>
</span>
</Tooltip> </Tooltip>
<SellSharesModal open={sellSharesOpen} onClose={() => setSellSharesOpen(false)} rerender={rerender} /> <SellSharesModal open={sellSharesOpen} onClose={() => setSellSharesOpen(false)} rerender={rerender} />
<Tooltip title={<Typography>Buy back shares you that previously issued or sold at market price.</Typography>}> <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)}>
<Button disabled={corp.issuedShares < 1} onClick={() => setBuybackSharesOpen(true)}> Buyback shares
Buyback shares </Button>
</Button>
</span>
</Tooltip> </Tooltip>
<BuybackSharesModal open={buybackSharesOpen} onClose={() => setBuybackSharesOpen(false)} rerender={rerender} /> <BuybackSharesModal open={buybackSharesOpen} onClose={() => setBuybackSharesOpen(false)} rerender={rerender} />
<br />
<Tooltip title={<Typography>{issueNewSharesTooltip}</Typography>}> <Tooltip title={<Typography>{issueNewSharesTooltip}</Typography>}>
<span> <Button disabled={issueNewSharesOnCd} onClick={() => setIssueNewSharesOpen(true)}>
<Button disabled={issueNewSharesOnCd} onClick={() => setIssueNewSharesOpen(true)}> Issue New Shares
Issue New Shares </Button>
</Button>
</span>
</Tooltip> </Tooltip>
<IssueNewSharesModal open={issueNewSharesOpen} onClose={() => setIssueNewSharesOpen(false)} /> <IssueNewSharesModal open={issueNewSharesOpen} onClose={() => setIssueNewSharesOpen(false)} />
<Tooltip <Tooltip
@ -242,7 +234,6 @@ function PublicButtons({ rerender }: IPublicButtonsProps): React.ReactElement {
<Button onClick={() => setIssueDividendsOpen(true)}>Issue Dividends</Button> <Button onClick={() => setIssueDividendsOpen(true)}>Issue Dividends</Button>
</Tooltip> </Tooltip>
<IssueDividendsModal open={issueDividendsOpen} onClose={() => setIssueDividendsOpen(false)} /> <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" : "Your Corporation is not powerful enough to bribe Faction leaders"
} }
> >
<span> <Button disabled={!canBribe} onClick={openBribe}>
<Button disabled={!canBribe} onClick={openBribe}> Bribe Factions
Bribe Factions </Button>
</Button>
</span>
</Tooltip> </Tooltip>
<BribeFactionModal open={open} onClose={() => setOpen(false)} /> <BribeFactionModal open={open} onClose={() => setOpen(false)} />
</> </>

@ -81,7 +81,7 @@ export function ProductElem(props: IProductProps): React.ReactElement {
); );
} else if (product.sCost) { } else if (product.sCost) {
if (isString(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 = (
<> <>
{sellButtonText} @ <Money money={eval(sCost)} /> {sellButtonText} @ <Money money={eval(sCost)} />

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

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

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

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

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

@ -2,20 +2,27 @@
* React Component for the popup that manages gang members upgrades * React Component for the popup that manages gang members upgrades
*/ */
import React, { useState } from "react"; 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 { useGang } from "./Context";
import { GangMember } from "../GangMember"; import { generateTableRow } from "./GangMemberStats";
import { UpgradeType } from "../data/upgrades";
import { use } from "../../ui/Context";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper"; 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 { interface INextRevealProps {
upgrades: string[]; upgrades: string[];
@ -46,12 +53,10 @@ function NextReveal(props: INextRevealProps): React.ReactElement {
function PurchasedUpgrade({ upgName }: { upgName: string }): React.ReactElement { function PurchasedUpgrade({ upgName }: { upgName: string }): React.ReactElement {
const upg = GangMemberUpgrades[upgName]; const upg = GangMemberUpgrades[upgName];
return ( return (
<Paper sx={{ mx: 1, p: 1 }}> <Paper sx={{ p: 1 }}>
<Box display="flex"> <Tooltip title={<Typography dangerouslySetInnerHTML={{ __html: upg.desc }} />}>
<Tooltip title={<Typography dangerouslySetInnerHTML={{ __html: upg.desc }} />}> <Typography>{upg.name}</Typography>
<Typography>{upg.name}</Typography> </Tooltip>
</Tooltip>
</Box>
</Paper> </Paper>
); );
} }
@ -72,8 +77,8 @@ function UpgradeButton(props: IUpgradeButtonProps): React.ReactElement {
return ( return (
<Tooltip title={<Typography dangerouslySetInnerHTML={{ __html: props.upg.desc }} />}> <Tooltip title={<Typography dangerouslySetInnerHTML={{ __html: props.upg.desc }} />}>
<span> <span>
<Typography>{props.upg.name}</Typography> <Button onClick={onClick} sx={{ display: 'flex', flexDirection: 'column', width: '100%', height: '100%' }}>
<Button onClick={onClick}> <Typography sx={{ display: 'block' }}>{props.upg.name}</Typography>
<Money money={gang.getUpgradeCost(props.upg)} /> <Money money={gang.getUpgradeCost(props.upg)} />
</Button> </Button>
</span> </span>
@ -86,12 +91,16 @@ interface IPanelProps {
} }
function GangMemberUpgradePanel(props: IPanelProps): React.ReactElement { function GangMemberUpgradePanel(props: IPanelProps): React.ReactElement {
const classes = useStyles();
const gang = useGang(); const gang = useGang();
const player = use.Player(); const player = use.Player();
const setRerender = useState(false)[1]; const setRerender = useState(false)[1];
const [currentCategory, setCurrentCategory] = useState("Weapons");
function rerender(): void { function rerender(): void {
setRerender((old) => !old); setRerender((old) => !old);
} }
function filterUpgrades(list: string[], type: UpgradeType): GangMemberUpgrade[] { function filterUpgrades(list: string[], type: UpgradeType): GangMemberUpgrade[] {
return Object.keys(GangMemberUpgrades) return Object.keys(GangMemberUpgrades)
.filter((upgName: string) => { .filter((upgName: string) => {
@ -103,12 +112,26 @@ function GangMemberUpgradePanel(props: IPanelProps): React.ReactElement {
}) })
.map((upgName: string) => GangMemberUpgrades[upgName]); .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 weaponUpgrades = filterUpgrades(props.member.upgrades, UpgradeType.Weapon);
const armorUpgrades = filterUpgrades(props.member.upgrades, UpgradeType.Armor); const armorUpgrades = filterUpgrades(props.member.upgrades, UpgradeType.Armor);
const vehicleUpgrades = filterUpgrades(props.member.upgrades, UpgradeType.Vehicle); const vehicleUpgrades = filterUpgrades(props.member.upgrades, UpgradeType.Vehicle);
const rootkitUpgrades = filterUpgrades(props.member.upgrades, UpgradeType.Rootkit); const rootkitUpgrades = filterUpgrades(props.member.upgrades, UpgradeType.Rootkit);
const augUpgrades = filterUpgrades(props.member.augmentations, UpgradeType.Augmentation); 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 = { const asc = {
hack: props.member.calculateAscensionMult(props.member.hack_asc_points), hack: props.member.calculateAscensionMult(props.member.hack_asc_points),
str: props.member.calculateAscensionMult(props.member.str_asc_points), str: props.member.calculateAscensionMult(props.member.str_asc_points),
@ -119,26 +142,89 @@ function GangMemberUpgradePanel(props: IPanelProps): React.ReactElement {
}; };
return ( return (
<Paper> <Paper>
<Typography variant="h5" color="primary"> <Box display="grid" sx={{ gridTemplateColumns: '1fr 1fr', m: 1, gap: 1 }}>
{props.member.name} ({props.member.task}) <span>
</Typography> <Typography variant="h5" color="primary">
<Typography> {props.member.name} ({props.member.task})
Hack: {props.member.hack} (x </Typography>
{formatNumber(props.member.hack_mult * asc.hack, 2)})<br /> <Tooltip
Str: {props.member.str} (x title={
{formatNumber(props.member.str_mult * asc.str, 2)})<br /> <Typography>
Def: {props.member.def} (x Hk: x{numeralWrapper.formatMultiplier(props.member.hack_mult * asc.hack)}(x
{formatNumber(props.member.def_mult * asc.def, 2)})<br /> {numeralWrapper.formatMultiplier(props.member.hack_mult)} Eq, x{numeralWrapper.formatMultiplier(asc.hack)}{" "}
Dex: {props.member.dex} (x Asc)
{formatNumber(props.member.dex_mult * asc.dex, 2)})<br /> <br />
Agi: {props.member.agi} (x St: x{numeralWrapper.formatMultiplier(props.member.str_mult * asc.str)}
{formatNumber(props.member.agi_mult * asc.agi, 2)})<br /> (x{numeralWrapper.formatMultiplier(props.member.str_mult)} Eq, x{numeralWrapper.formatMultiplier(asc.str)}{" "}
Cha: {props.member.cha} (x Asc)
{formatNumber(props.member.cha_mult * asc.cha, 2)}) <br />
</Typography> Df: x{numeralWrapper.formatMultiplier(props.member.def_mult * asc.def)}
<Box display="flex" flexWrap="wrap"> (x{numeralWrapper.formatMultiplier(props.member.def_mult)} Eq, x{numeralWrapper.formatMultiplier(asc.def)}{" "}
<Typography>Purchased Upgrades: </Typography> Asc)
<br /> <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) => ( {props.member.upgrades.map((upg: string) => (
<PurchasedUpgrade key={upg} upgName={upg} /> <PurchasedUpgrade key={upg} upgName={upg} />
))} ))}
@ -146,59 +232,22 @@ function GangMemberUpgradePanel(props: IPanelProps): React.ReactElement {
<PurchasedUpgrade key={upg} upgName={upg} /> <PurchasedUpgrade key={upg} upgName={upg} />
))} ))}
</Box> </Box>
<Box display="flex" justifyContent="space-around"> </Paper >
<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>
); );
} }
export function EquipmentsSubpage(): React.ReactElement { export function EquipmentsSubpage(): React.ReactElement {
const gang = useGang(); 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 ( return (
<> <>
<Tooltip <Tooltip
@ -209,11 +258,26 @@ export function EquipmentsSubpage(): React.ReactElement {
</Typography> </Typography>
} }
> >
<Typography>Discount: -{numeralWrapper.formatPercentage(1 - 1 / gang.getDiscount())}</Typography> <Typography sx={{ m: 1 }}>Discount: -{numeralWrapper.formatPercentage(1 - 1 / gang.getDiscount())}</Typography>
</Tooltip> </Tooltip>
{gang.members.map((member: GangMember) => (
<GangMemberUpgradePanel 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%' }}
/>
<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. * React Component for the list of gang members on the management subpage.
*/ */
import React, { useState } from "react"; import React, { useState } from "react";
import { GangMemberAccordion } from "./GangMemberAccordion"; import { GangMemberCard } from "./GangMemberCard";
import { GangMember } from "../GangMember";
import { RecruitButton } from "./RecruitButton"; import { RecruitButton } from "./RecruitButton";
import { useGang } from "./Context"; 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 { export function GangMemberList(): React.ReactElement {
const gang = useGang(); const gang = useGang();
const setRerender = useState(false)[1]; 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 ( return (
<> <>
<RecruitButton onRecruit={() => setRerender((old) => !old)} /> <RecruitButton onRecruit={() => setRerender((old) => !old)} />
<ul> <TextField
{gang.members.map((member: GangMember) => ( value={filter}
<GangMemberAccordion key={member.name} member={member} /> 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. * React Component for the first part of a gang member details.
* Contains skills and exp. * Contains skills and exp.
*/ */
import React, { useState } from "react"; import React from "react";
import { formatNumber } from "../../utils/StringHelperFunctions"; import { useGang } from "./Context";
import { numeralWrapper } from "../../ui/numeralFormat";
import { GangMember } from "../GangMember";
import { AscensionModal } from "./AscensionModal";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import Button from "@mui/material/Button"; import {
import { StaticModal } from "../../ui/React/StaticModal"; Table,
import IconButton from "@mui/material/IconButton"; TableBody,
import HelpIcon from "@mui/icons-material/Help"; 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 { interface IProps {
member: GangMember; 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 { export function GangMemberStats(props: IProps): React.ReactElement {
const [helpOpen, setHelpOpen] = useState(false); const classes = useStyles();
const [ascendOpen, setAscendOpen] = useState(false);
const asc = { const asc = {
hack: props.member.calculateAscensionMult(props.member.hack_asc_points), 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), 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 ( return (
<> <>
<Tooltip <Tooltip
@ -63,50 +100,32 @@ export function GangMemberStats(props: IProps): React.ReactElement {
</Typography> </Typography>
} }
> >
<Typography> <Table sx={{ display: 'table', mb: 1, width: '100%' }}>
Hacking: {formatNumber(props.member.hack, 0)} ({numeralWrapper.formatExp(props.member.hack_exp)} exp) <TableBody>
<br /> {generateTableRow("Hacking", props.member.hack, props.member.hack_exp, Settings.theme.hack, classes)}
Strength: {formatNumber(props.member.str, 0)} ({numeralWrapper.formatExp(props.member.str_exp)} exp) {generateTableRow("Strength", props.member.str, props.member.str_exp, Settings.theme.combat, classes)}
<br /> {generateTableRow("Defense", props.member.def, props.member.def_exp, Settings.theme.combat, classes)}
Defense: {formatNumber(props.member.def, 0)} ({numeralWrapper.formatExp(props.member.def_exp)} exp) {generateTableRow("Dexterity", props.member.dex, props.member.dex_exp, Settings.theme.combat, classes)}
<br /> {generateTableRow("Agility", props.member.agi, props.member.agi_exp, Settings.theme.combat, classes)}
Dexterity: {formatNumber(props.member.dex, 0)} ({numeralWrapper.formatExp(props.member.dex_exp)} exp) {generateTableRow("Charisma", props.member.cha, props.member.cha_exp, Settings.theme.cha, classes)}
<br /> <TableRow>
Agility: {formatNumber(props.member.agi, 0)} ({numeralWrapper.formatExp(props.member.agi_exp)} exp) <TableCell classes={{ root: classes.cellNone }}>
<br /> <br />
Charisma: {formatNumber(props.member.cha, 0)} ({numeralWrapper.formatExp(props.member.cha_exp)} exp) </TableCell>
<br /> </TableRow>
</Typography> {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> </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()) { if (!gang.canRecruitMember()) {
const respect = gang.getRespectNeededToRecruitMember(); const respect = gang.getRespectNeededToRecruitMember();
return ( return (
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center" sx={{ mx: 1 }}>
<Button sx={{ mx: 1 }} disabled> <Button disabled>
Recruit Gang Member Recruit Gang Member
</Button> </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> </Box>
); );
} }
return ( return (
<> <>
<Button onClick={() => setOpen(true)}>Recruit Gang Member</Button> <Box sx={{ mx: 1 }}>
<Button onClick={() => setOpen(true)}>Recruit Gang Member</Button>
</Box>
<RecruitModal open={open} onClose={() => setOpen(false)} onRecruit={props.onRecruit} /> <RecruitModal open={open} onClose={() => setOpen(false)} onRecruit={props.onRecruit} />
</> </>
); );

@ -3,14 +3,15 @@
* the task selector as well as some stats. * the task selector as well as some stats.
*/ */
import React, { useState } from "react"; 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 { useGang } from "./Context";
import { GangMember } from "../GangMember"; import { TaskDescription } from "./TaskDescription";
import { Box } from "@mui/material";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import Select, { SelectChangeEvent } from "@mui/material/Select"; import Select, { SelectChangeEvent } from "@mui/material/Select";
import { GangMember } from "../GangMember";
interface IProps { interface IProps {
member: GangMember; member: GangMember;
onTaskChange: () => void; onTaskChange: () => void;
@ -29,16 +30,9 @@ export function TaskSelector(props: IProps): React.ReactElement {
const tasks = gang.getAllTaskNames(); 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 ( return (
<> <Box>
<Select onChange={onChange} value={currentTask}> <Select onChange={onChange} value={currentTask} sx={{ width: '100%' }}>
<MenuItem key={0} value={"Unassigned"}> <MenuItem key={0} value={"Unassigned"}>
Unassigned Unassigned
</MenuItem> </MenuItem>
@ -48,8 +42,7 @@ export function TaskSelector(props: IProps): React.ReactElement {
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
<TaskDescription member={props.member} />
<StatsTable rows={data} /> </Box>
</>
); );
} }

@ -34,9 +34,9 @@ export function calculateHackingExpGain(server: Server, player: IPlayer): number
server.baseDifficulty = server.hackDifficulty; server.baseDifficulty = server.hackDifficulty;
} }
let expGain = baseExpGain; 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 React, { useState } from "react";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import { Blackjack } from "../../Casino/Blackjack"; import { Blackjack, DECK_COUNT } from "../../Casino/Blackjack";
import { CoinFlip } from "../../Casino/CoinFlip"; import { CoinFlip } from "../../Casino/CoinFlip";
import { Roulette } from "../../Casino/Roulette"; import { Roulette } from "../../Casino/Roulette";
import { SlotMachine } from "../../Casino/SlotMachine"; 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.Coin)}>Play coin flip</Button>
<Button onClick={() => updateGame(GameType.Slots)}>Play slots</Button> <Button onClick={() => updateGame(GameType.Slots)}>Play slots</Button>
<Button onClick={() => updateGame(GameType.Roulette)}>Play roulette</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> </Box>
)} )}
{game !== GameType.None && ( {game !== GameType.None && (

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

@ -553,7 +553,7 @@ export function NetscriptFunctions(workerScript: WorkerScript): NS {
if (isNaN(hackAmount)) { if (isNaN(hackAmount)) {
throw makeRuntimeErrorMsg( throw makeRuntimeErrorMsg(
"hackAnalyzeThreads", "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)); 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 { tprint: function (...args: any[]): void {
if (args.length === 0) { if (args.length === 0) {
throw makeRuntimeErrorMsg("tprint", "Takes at least 1 argument."); throw makeRuntimeErrorMsg("tprint", "Takes at least 1 argument.");
@ -1676,7 +1682,12 @@ export function NetscriptFunctions(workerScript: WorkerScript): NS {
const cost = getPurchaseServerCost(ram); const cost = getPurchaseServerCost(ram);
if (cost === Infinity) { if (cost === Infinity) {
workerScript.log("purchaseServer", () => `Invalid argument: ram='${ram}' must be a positive power of 2`); 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 ""; return "";
} }

@ -49,6 +49,7 @@ import {
SetMaterialMarketTA2, SetMaterialMarketTA2,
SetProductMarketTA1, SetProductMarketTA1,
SetProductMarketTA2, SetProductMarketTA2,
SetSmartSupplyUseLeftovers,
} from "../Corporation/Actions"; } from "../Corporation/Actions";
import { CorporationUnlockUpgrades } from "../Corporation/data/CorporationUnlockUpgrades"; import { CorporationUnlockUpgrades } from "../Corporation/data/CorporationUnlockUpgrades";
import { CorporationUpgrades } from "../Corporation/data/CorporationUpgrades"; import { CorporationUpgrades } from "../Corporation/data/CorporationUpgrades";
@ -410,6 +411,16 @@ export function NetscriptCorporation(
const warehouse = getWarehouse(divisionName, cityName); const warehouse = getWarehouse(divisionName, cityName);
SetSmartSupply(warehouse, enabled); 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 { buyMaterial: function (adivisionName: any, acityName: any, amaterialName: any, aamt: any): void {
checkAccess("buyMaterial", 7); checkAccess("buyMaterial", 7);
const divisionName = helper.string("buyMaterial", "divisionName", adivisionName); const divisionName = helper.string("buyMaterial", "divisionName", adivisionName);

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

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

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

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

@ -35,7 +35,9 @@ import { CityName } from "../../Locations/data/CityNames";
import { MoneySourceTracker } from "../../utils/MoneySourceTracker"; import { MoneySourceTracker } from "../../utils/MoneySourceTracker";
import { Reviver, Generic_toJSON, Generic_fromJSON } from "../../utils/JSONReviver"; import { Reviver, Generic_toJSON, Generic_fromJSON } from "../../utils/JSONReviver";
import { ISkillProgress } from "../formulas/skill"; import { ISkillProgress } from "../formulas/skill";
import { PlayerAchievement } from '../../Achievements/Achievements'; import { PlayerAchievement } from "../../Achievements/Achievements";
import { cyrb53 } from "../../utils/StringHelperFunctions";
import { getRandomInt } from "../../utils/helpers/getRandomInt";
export class PlayerObject implements IPlayer { export class PlayerObject implements IPlayer {
// Class members // Class members
@ -77,7 +79,10 @@ export class PlayerObject implements IPlayer {
sourceFiles: IPlayerOwnedSourceFile[]; sourceFiles: IPlayerOwnedSourceFile[];
exploits: Exploit[]; exploits: Exploit[];
achievements: PlayerAchievement[]; achievements: PlayerAchievement[];
terminalCommandHistory: string[];
identifier: string;
lastUpdate: number; lastUpdate: number;
lastSave: number;
totalPlaytime: number; totalPlaytime: number;
// Stats // Stats
@ -459,7 +464,9 @@ export class PlayerObject implements IPlayer {
//Used to store the last update time. //Used to store the last update time.
this.lastUpdate = 0; this.lastUpdate = 0;
this.lastSave = 0;
this.totalPlaytime = 0; this.totalPlaytime = 0;
this.playtimeSinceLastAug = 0; this.playtimeSinceLastAug = 0;
this.playtimeSinceLastBitnode = 0; this.playtimeSinceLastBitnode = 0;
@ -471,6 +478,17 @@ export class PlayerObject implements IPlayer {
this.exploits = []; this.exploits = [];
this.achievements = []; 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.init = generalMethods.init;
this.prestigeAugmentation = generalMethods.prestigeAugmentation; this.prestigeAugmentation = generalMethods.prestigeAugmentation;

@ -1,5 +1,8 @@
import { Sleeve } from "../Sleeve"; import { Sleeve } from "../Sleeve";
import { numeralWrapper } from "../../../ui/numeralFormat"; 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 { StatsTable } from "../../../ui/React/StatsTable";
import { Modal } from "../../../ui/React/Modal"; import { Modal } from "../../../ui/React/Modal";
import React from "react"; import React from "react";
@ -80,6 +83,13 @@ export function MoreStatsModal(props: IProps): React.ReactElement {
]} ]}
title="Multipliers:" 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> </Modal>
); );
} }

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

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

@ -33,6 +33,8 @@ import Typography from "@mui/material/Typography";
import Link from "@mui/material/Link"; import Link from "@mui/material/Link";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import SettingsIcon from "@mui/icons-material/Settings"; 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 Table from "@mui/material/Table";
import TableCell from "@mui/material/TableCell"; import TableCell from "@mui/material/TableCell";
import TableRow from "@mui/material/TableRow"; import TableRow from "@mui/material/TableRow";
@ -41,6 +43,7 @@ import { PromptEvent } from "../../ui/React/PromptManager";
import { Modal } from "../../ui/React/Modal"; import { Modal } from "../../ui/React/Modal";
import libSource from "!!raw-loader!../NetscriptDefinitions.d.ts"; import libSource from "!!raw-loader!../NetscriptDefinitions.d.ts";
import { Tooltip } from "@mui/material";
interface IProps { interface IProps {
// Map of filename -> code // 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 { 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 openScript = openScripts[index];
const server = GetServer(openScript.hostname); const server = GetServer(openScript.hostname);
if (server === null) throw new Error(`Server '${openScript.hostname}' should not be null, but it is.`); 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); const serverScript = server.scripts.find((s) => s.filename === openScript.fileName);
if (serverScript === undefined) return " *"; return serverScript?.code ?? null;
// The server code is stored with its starting & trailing whitespace removed
const openScriptFormatted = Script.formatCode(openScript.code);
return serverScript.code !== openScriptFormatted ? " *" : "";
} }
// Toolbars are roughly 112px: // Toolbars are roughly 112px:
@ -738,62 +782,78 @@ export function Root(props: IProps): React.ReactElement {
overflowX: "scroll", overflowX: "scroll",
}} }}
> >
{openScripts.map(({ fileName, hostname }, index) => ( {openScripts.map(({ fileName, hostname }, index) => {
<Draggable const iconButtonStyle = {
key={fileName + hostname} maxWidth: "25px",
draggableId={fileName + hostname} minWidth: "25px",
index={index} minHeight: "38.5px",
disableInteractiveElementBlocking={true} maxHeight: "38.5px",
> ...(currentScript?.fileName === openScripts[index].fileName
{(provided) => ( ? {
<div background: Settings.theme.button,
ref={provided.innerRef} borderColor: Settings.theme.button,
{...provided.draggableProps} color: Settings.theme.primary,
{...provided.dragHandleProps} }
style={{ : {
...provided.draggableProps.style, background: Settings.theme.backgroundsecondary,
marginRight: "5px", borderColor: Settings.theme.backgroundsecondary,
flexShrink: 0, color: Settings.theme.secondary,
}} }),
> };
<Button return (
onClick={() => onTabClick(index)} <Draggable
style={ key={fileName + hostname}
currentScript?.fileName === openScripts[index].fileName draggableId={fileName + hostname}
? { index={index}
background: Settings.theme.button, disableInteractiveElementBlocking={true}
color: Settings.theme.primary, >
} {(provided) => (
: { <div
background: Settings.theme.backgroundsecondary, ref={provided.innerRef}
color: Settings.theme.secondary, {...provided.draggableProps}
} {...provided.dragHandleProps}
}
>
{hostname}:~/{fileName} {dirty(index)}
</Button>
<Button
onClick={() => onTabClose(index)}
style={{ style={{
maxWidth: "20px", ...provided.draggableProps.style,
minWidth: "20px", marginRight: "5px",
...(currentScript?.fileName === openScripts[index].fileName flexShrink: 0,
? { border: "1px solid " + Settings.theme.well,
background: Settings.theme.button,
color: Settings.theme.primary,
}
: {
background: Settings.theme.backgroundsecondary,
color: Settings.theme.secondary,
}),
}} }}
> >
x <Button
</Button> onClick={() => onTabClick(index)}
</div> onMouseDown={(e) => {
)} e.preventDefault();
</Draggable> if (e.button === 1) onTabClose(index);
))} }}
style={{
...(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,
}),
}}
>
{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} {provided.placeholder}
</Box> </Box>
)} )}

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

@ -1,7 +1,7 @@
import { ISelfInitializer, ISelfLoading } from "../types"; import { ISelfInitializer, ISelfLoading } from "../types";
import { OwnedAugmentationsOrderSetting, PurchaseAugmentationsOrderSetting } from "./SettingEnums"; import { OwnedAugmentationsOrderSetting, PurchaseAugmentationsOrderSetting } from "./SettingEnums";
import { defaultTheme, ITheme } from "./Themes"; import { defaultTheme, ITheme } from "../Themes/Themes";
import { defaultStyles } from "./Styles"; import { defaultStyles } from "../Themes/Styles";
import { WordWrapOptions } from "../ScriptEditor/ui/Options"; import { WordWrapOptions } from "../ScriptEditor/ui/Options";
import { OverviewSettings } from "../ui/React/Overview"; import { OverviewSettings } from "../ui/React/Overview";
import { IStyleSettings } from "../ScriptEditor/NetscriptDefinitions"; 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.", " grow Spoof money in a servers bank account, increasing the amount available.",
" hack Hack the current machine", " hack Hack the current machine",
" help [command] Display this help text, or the help text for a command", " help [command] Display this help text, or the help text for a command",
" history [-c] Display the terminal history",
" home Connect to home computer", " home Connect to home computer",
" hostname Displays the hostname of the machine", " hostname Displays the hostname of the machine",
" kill [script/pid] [args...] Stops the specified script on the current server ", " kill [script/pid] [args...] Stops the specified script on the current server ",
@ -255,6 +256,12 @@ export const HelpTexts: IMap<string[]> = {
" help scan-analyze", " help scan-analyze",
" ", " ",
], ],
history: [
"Usage: history [-c]",
" ",
"Without arguments, displays the terminal command history. To clear the history, pass in the '-c' argument.",
" ",
],
home: [ home: [
"Usage: home", " ", "Connect to your home computer. This will work no matter what server you are currently connected to.", " ", "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 { grow } from "./commands/grow";
import { hack } from "./commands/hack"; import { hack } from "./commands/hack";
import { help } from "./commands/help"; import { help } from "./commands/help";
import { history } from "./commands/history";
import { home } from "./commands/home"; import { home } from "./commands/home";
import { hostname } from "./commands/hostname"; import { hostname } from "./commands/hostname";
import { kill } from "./commands/kill"; import { kill } from "./commands/kill";
@ -143,7 +144,7 @@ export class Terminal implements ITerminal {
startGrow(player: IPlayer): void { startGrow(player: IPlayer): void {
const server = player.getCurrentServer(); const server = player.getCurrentServer();
if (server instanceof HacknetServer) { if (server instanceof HacknetServer) {
this.error("Cannot hack this kind of server"); this.error("Cannot grow this kind of server");
return; return;
} }
if (!(server instanceof Server)) throw new Error("server should be normal server"); 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 { startWeaken(player: IPlayer): void {
const server = player.getCurrentServer(); const server = player.getCurrentServer();
if (server instanceof HacknetServer) { if (server instanceof HacknetServer) {
this.error("Cannot hack this kind of server"); this.error("Cannot weaken this kind of server");
return; return;
} }
if (!(server instanceof Server)) throw new Error("server should be normal server"); 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 (cancelled) return;
if (server instanceof HacknetServer) { if (server instanceof HacknetServer) {
this.error("Cannot hack this kind of server"); this.error("Cannot grow this kind of server");
return; return;
} }
if (!(server instanceof Server)) throw new Error("server should be normal server"); 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 (cancelled) return;
if (server instanceof HacknetServer) { if (server instanceof HacknetServer) {
this.error("Cannot hack this kind of server"); this.error("Cannot weaken this kind of server");
return; return;
} }
if (!(server instanceof Server)) throw new Error("server should be normal server"); 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) { if (this.commandHistory.length > 50) {
this.commandHistory.splice(0, 1); this.commandHistory.splice(0, 1);
} }
player.terminalCommandHistory = this.commandHistory;
} }
this.commandHistoryIndex = this.commandHistory.length; this.commandHistoryIndex = this.commandHistory.length;
const allCommands = ParseCommands(commands); const allCommands = ParseCommands(commands);
@ -785,6 +787,7 @@ export class Terminal implements ITerminal {
grow: grow, grow: grow,
hack: hack, hack: hack,
help: help, help: help,
history: history,
home: home, home: home,
hostname: hostname, hostname: hostname,
kill: kill, kill: kill,

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

@ -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 [possibilities, setPossibilities] = useState<string[]>([]);
const classes = useStyles(); 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 // Need to run after state updates, for example if we need to move cursor
// *after* we modify input // *after* we modify input
useEffect(() => { 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