mirror of
https://github.com/bitburner-official/bitburner-src.git
synced 2026-04-16 06:18:42 +02:00
UI: Add option to enable/disable syncing Steam achievements (#2117)
This commit is contained in:
@@ -1,25 +1,27 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const { ipcMain } = require("electron");
|
||||
const { steamworksClient } = require("./steamworksUtils");
|
||||
const log = require("electron-log");
|
||||
|
||||
function enableAchievementsInterval(window) {
|
||||
function enableSyncingAchievements() {
|
||||
// If the Steam API could not be initialized on game start, we'll abort this.
|
||||
if (!steamworksClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This is backward but the game fills in an array called `document.achievements` and we retrieve it from
|
||||
// here. Hey if it works it works.
|
||||
const allSteamAchievements = steamworksClient.achievement.names();
|
||||
log.silly(`All Steam achievements ${JSON.stringify(allSteamAchievements)}`);
|
||||
const steamAchievements = allSteamAchievements.filter((achievement) =>
|
||||
steamworksClient.achievement.isActivated(achievement),
|
||||
);
|
||||
log.debug(`Player has Steam achievements ${JSON.stringify(steamAchievements)}`);
|
||||
const intervalID = setInterval(async () => {
|
||||
|
||||
ipcMain.on("activate-achievements", async (_event, data) => {
|
||||
if (!data || !Array.isArray(data.achievements)) {
|
||||
log.info("Achievement list is invalid. Data:", data);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const playerAchievements = await window.webContents.executeJavaScript("document.achievements");
|
||||
for (const achievement of playerAchievements) {
|
||||
for (const achievement of data.achievements) {
|
||||
// Don't try activating achievements that don't exist Steam-side
|
||||
if (!allSteamAchievements.includes(achievement)) {
|
||||
continue;
|
||||
@@ -37,22 +39,10 @@ function enableAchievementsInterval(window) {
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
|
||||
// The interval probably did not get cleared after a window kill
|
||||
log.warn("Clearing achievements timer");
|
||||
clearInterval(intervalID);
|
||||
}
|
||||
}, 1000);
|
||||
window.achievementsIntervalID = intervalID;
|
||||
}
|
||||
|
||||
function disableAchievementsInterval(window) {
|
||||
if (window.achievementsIntervalID) {
|
||||
clearInterval(window.achievementsIntervalID);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
enableAchievementsInterval,
|
||||
disableAchievementsInterval,
|
||||
enableSyncingAchievements,
|
||||
};
|
||||
|
||||
@@ -59,7 +59,7 @@ async function createWindow(killall) {
|
||||
|
||||
window.webContents.backgroundThrottling = false;
|
||||
|
||||
achievements.enableAchievementsInterval(window);
|
||||
achievements.enableSyncingAchievements();
|
||||
utils.attachUnresponsiveAppHandler(window);
|
||||
|
||||
menu.refreshMenu(window);
|
||||
|
||||
@@ -21,7 +21,6 @@ app.on("window-all-closed", () => {
|
||||
|
||||
require("./steamworksUtils");
|
||||
const gameWindow = require("./gameWindow");
|
||||
const achievements = require("./achievements");
|
||||
const utils = require("./utils");
|
||||
const storage = require("./storage");
|
||||
const debounce = require("lodash/debounce");
|
||||
@@ -43,9 +42,6 @@ function setStopProcessHandler(window) {
|
||||
// We need to prevent the default closing event to add custom logic
|
||||
e.preventDefault();
|
||||
|
||||
// First we clear the achievement timer
|
||||
achievements.disableAchievementsInterval(window);
|
||||
|
||||
// Trigger debounced saves right now before closing
|
||||
try {
|
||||
await saveToDisk.flush();
|
||||
|
||||
@@ -11,6 +11,7 @@ contextBridge.exposeInMainWorld("electronBridge", {
|
||||
"push-game-ready",
|
||||
"push-import-result",
|
||||
"push-disable-restore",
|
||||
"activate-achievements",
|
||||
];
|
||||
if (validChannels.includes(channel)) {
|
||||
ipcRenderer.send(channel, data);
|
||||
|
||||
@@ -31,6 +31,8 @@ import { getRecordValues } from "../Types/Record";
|
||||
import { ServerConstants } from "../Server/data/Constants";
|
||||
import { canAccessBitNodeFeature, isBitNodeFinished, knowAboutBitverse } from "../BitNode/BitNodeUtils";
|
||||
import { isLegacyScript } from "../Paths/ScriptFilePath";
|
||||
import { Settings } from "../Settings/Settings";
|
||||
import { activateSteamAchievements } from "../Electron";
|
||||
|
||||
// Unable to correctly cast the JSON data into AchievementDataJson type otherwise...
|
||||
const achievementData = (<AchievementDataJson>(<unknown>data)).achievements;
|
||||
@@ -719,8 +721,16 @@ export function calculateAchievements(): void {
|
||||
Player.giveAchievement(id);
|
||||
}
|
||||
|
||||
// Write all player's achievements to document for Steam/Electron
|
||||
// This could be replaced by "availableAchievements"
|
||||
// if we don't want to grant the save game achievements to steam but only currently available
|
||||
document.achievements = [...Player.achievements.map((a) => a.ID)];
|
||||
if (Settings.SyncSteamAchievements) {
|
||||
activateSteamAchievements(
|
||||
Player.achievements
|
||||
.map((a) => a.ID)
|
||||
.filter((name) => {
|
||||
if (!achievements[name]) {
|
||||
return false;
|
||||
}
|
||||
return !achievements[name].NotInSteam;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,3 +207,11 @@ export function pushDisableRestore(): void {
|
||||
|
||||
bridge.send("push-disable-restore", { duration: 1000 * 60 });
|
||||
}
|
||||
|
||||
export function activateSteamAchievements(achievements: string[]): void {
|
||||
const bridge = window.electronBridge;
|
||||
if (!bridge) {
|
||||
return;
|
||||
}
|
||||
bridge.send("activate-achievements", { achievements });
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Page } from "../../ui/Router";
|
||||
import { convertTimeMsToTimeElapsedString } from "../../utils/StringHelperFunctions";
|
||||
import { OptionsTabName } from "./GameOptionsRoot";
|
||||
import { Player } from "@player";
|
||||
import { OptionSwitch } from "../../ui/React/OptionSwitch";
|
||||
|
||||
interface IProps {
|
||||
tab: OptionsTabName;
|
||||
@@ -59,6 +60,7 @@ export const GameOptionsSidebar = (props: IProps): React.ReactElement => {
|
||||
const [diagnosticOpen, setDiagnosticOpen] = useState(false);
|
||||
const [importSaveOpen, setImportSaveOpen] = useState(false);
|
||||
const [importData, setImportData] = useState<ImportData | null>(null);
|
||||
const [syncSteamAchievements, setSyncSteamAchievements] = useState(true);
|
||||
|
||||
const [confirmResetOpen, setConfirmResetOpen] = useState(false);
|
||||
const [creditsOpen, setCreditsOpen] = useState(false);
|
||||
@@ -76,6 +78,7 @@ export const GameOptionsSidebar = (props: IProps): React.ReactElement => {
|
||||
const data = await saveObject.getImportDataFromSaveData(saveData);
|
||||
setImportData(data);
|
||||
setImportSaveOpen(true);
|
||||
setSyncSteamAchievements(data.playerData?.syncSteamAchievements ?? true);
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
SnackbarEvents.emit(String(e), ToastVariant.ERROR, 5000);
|
||||
@@ -89,7 +92,13 @@ export const GameOptionsSidebar = (props: IProps): React.ReactElement => {
|
||||
if (!importData) return;
|
||||
|
||||
try {
|
||||
await saveObject.importGame(importData.saveData);
|
||||
let overrideSettings = undefined;
|
||||
if (syncSteamAchievements !== importData.playerData?.syncSteamAchievements) {
|
||||
overrideSettings = {
|
||||
SyncSteamAchievements: syncSteamAchievements,
|
||||
};
|
||||
}
|
||||
await saveObject.importGame(importData.saveData, overrideSettings);
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
SnackbarEvents.emit(String(e), ToastVariant.ERROR, 5000);
|
||||
@@ -205,6 +214,18 @@ export const GameOptionsSidebar = (props: IProps): React.ReactElement => {
|
||||
)}
|
||||
<br />
|
||||
<br />
|
||||
<OptionSwitch
|
||||
checked={syncSteamAchievements}
|
||||
onChange={(newValue) => setSyncSteamAchievements(newValue)}
|
||||
text="Sync Steam achievements"
|
||||
tooltip={
|
||||
<>
|
||||
This setting is only used in the Steam app. If this setting is enabled, the game will automatically
|
||||
sync your unlocked Steam achievements to Steam Cloud.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<br />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -54,6 +54,17 @@ export const MiscPage = (): React.ReactElement => {
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<OptionSwitch
|
||||
checked={Settings.SyncSteamAchievements}
|
||||
onChange={(newValue) => (Settings.SyncSteamAchievements = newValue)}
|
||||
text="Sync Steam achievements"
|
||||
tooltip={
|
||||
<>
|
||||
This setting is only used in the Steam app. If this setting is enabled, the game will automatically sync
|
||||
your unlocked Steam achievements to Steam Cloud.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</GameOptionsPage>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface ImportPlayerData {
|
||||
bitNodeLevel: number;
|
||||
sourceFiles: number;
|
||||
exploits: number;
|
||||
syncSteamAchievements: boolean;
|
||||
}
|
||||
|
||||
export type BitburnerSaveObjectType = {
|
||||
@@ -83,6 +84,13 @@ export type BitburnerSaveObjectType = {
|
||||
GoSave: unknown; // "loadGo" function can process unknown data
|
||||
};
|
||||
|
||||
type ParsedSaveData = {
|
||||
data: {
|
||||
PlayerSave: string;
|
||||
SettingsSave: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* This function asserts the unknown saveObject.
|
||||
*
|
||||
@@ -136,6 +144,18 @@ function assertBitburnerSaveObjectType(saveObject: unknown): asserts saveObject
|
||||
}
|
||||
}
|
||||
|
||||
function assertParsedSaveData(parsedSaveData: unknown): asserts parsedSaveData is ParsedSaveData {
|
||||
if (
|
||||
!isObject(parsedSaveData) ||
|
||||
parsedSaveData.ctor !== "BitburnerSaveObject" ||
|
||||
!isObject(parsedSaveData.data) ||
|
||||
typeof parsedSaveData.data.PlayerSave !== "string"
|
||||
) {
|
||||
console.error("parsedSaveData:", parsedSaveData);
|
||||
throw new Error("The parsed save data is not valid.");
|
||||
}
|
||||
}
|
||||
|
||||
class BitburnerSaveObject implements BitburnerSaveObjectType {
|
||||
PlayerSave = "";
|
||||
AllServersSave = "";
|
||||
@@ -230,9 +250,37 @@ class BitburnerSaveObject implements BitburnerSaveObjectType {
|
||||
downloadContentAsFile(saveData, filename);
|
||||
}
|
||||
|
||||
async importGame(saveData: SaveData, reload = true): Promise<void> {
|
||||
async importGame(
|
||||
saveData: SaveData,
|
||||
overrideSettings?: {
|
||||
SyncSteamAchievements: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!saveData || saveData.length === 0) {
|
||||
throw new Error("Invalid import string");
|
||||
dialogBoxCreate("Invalid save data");
|
||||
return;
|
||||
}
|
||||
// Modify settings in save data if needed (i.e., toggle SyncSteamAchievements before importing).
|
||||
if (overrideSettings) {
|
||||
let parsedSaveData;
|
||||
try {
|
||||
parsedSaveData = await this.getParsedSaveData(saveData);
|
||||
// Validate SettingsSave
|
||||
if (parsedSaveData.data.SettingsSave && typeof parsedSaveData.data.SettingsSave === "string") {
|
||||
// Parse settings from data.SettingsSave
|
||||
const settings: unknown = JSON.parse(parsedSaveData.data.SettingsSave);
|
||||
assertObject(settings);
|
||||
// Modify setting
|
||||
settings.SyncSteamAchievements = overrideSettings.SyncSteamAchievements;
|
||||
// Save modified data back to saveData
|
||||
parsedSaveData.data.SettingsSave = JSON.stringify(settings);
|
||||
saveData = await encodeJsonSaveString(JSON.stringify(parsedSaveData));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
dialogBoxCreate(`Cannot override settings: ${error}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await save(saveData);
|
||||
@@ -246,58 +294,62 @@ class BitburnerSaveObject implements BitburnerSaveObjectType {
|
||||
dialogBoxCreate(`Cannot import save data: ${error}`);
|
||||
return;
|
||||
}
|
||||
if (reload) {
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
}
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
}
|
||||
|
||||
async getSaveDataFromFile(files: FileList | null): Promise<SaveData> {
|
||||
if (files === null) return Promise.reject(new Error("No file selected"));
|
||||
if (files === null) {
|
||||
throw new Error("No file selected");
|
||||
}
|
||||
const file = files[0];
|
||||
if (!file) return Promise.reject(new Error("Invalid file selected"));
|
||||
if (!file) {
|
||||
throw new Error("Invalid file selected");
|
||||
}
|
||||
|
||||
const rawData = new Uint8Array(await file.arrayBuffer());
|
||||
if (isBinaryFormat(rawData)) {
|
||||
return rawData;
|
||||
} else {
|
||||
return new TextDecoder().decode(rawData);
|
||||
}
|
||||
return new TextDecoder().decode(rawData);
|
||||
}
|
||||
|
||||
async getImportDataFromSaveData(saveData: SaveData): Promise<ImportData> {
|
||||
if (!saveData || saveData.length === 0) throw new Error("Invalid save data");
|
||||
async getParsedSaveData(saveData: SaveData): Promise<ParsedSaveData> {
|
||||
if (!saveData || saveData.length === 0) {
|
||||
throw new Error("Invalid save data");
|
||||
}
|
||||
|
||||
let decodedSaveData;
|
||||
try {
|
||||
decodedSaveData = await decodeSaveData(saveData);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
// Rethrow immediately if the error is SaveDataError; otherwise, handle it below.
|
||||
if (error instanceof SaveDataError) {
|
||||
return Promise.reject(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!decodedSaveData || decodedSaveData === "") {
|
||||
console.error("decodedSaveData:", decodedSaveData);
|
||||
return Promise.reject(new Error("Save game is invalid. The save data cannot be decoded."));
|
||||
console.error("saveData:", saveData);
|
||||
throw new Error("The save data cannot be decoded.");
|
||||
}
|
||||
|
||||
let parsedSaveData: unknown;
|
||||
try {
|
||||
parsedSaveData = JSON.parse(decodedSaveData);
|
||||
} catch (error) {
|
||||
console.error(error); // We'll handle below
|
||||
console.error("decodedSaveData:", decodedSaveData);
|
||||
throw new Error("The decoded save data is not valid.");
|
||||
}
|
||||
|
||||
if (
|
||||
!isObject(parsedSaveData) ||
|
||||
parsedSaveData.ctor !== "BitburnerSaveObject" ||
|
||||
!isObject(parsedSaveData.data) ||
|
||||
typeof parsedSaveData.data.PlayerSave !== "string"
|
||||
) {
|
||||
console.error("decodedSaveData:", decodedSaveData);
|
||||
return Promise.reject(new Error("Save game is invalid. The decoded save data is not valid."));
|
||||
}
|
||||
assertParsedSaveData(parsedSaveData);
|
||||
|
||||
return parsedSaveData;
|
||||
}
|
||||
|
||||
async getImportDataFromSaveData(saveData: SaveData): Promise<ImportData> {
|
||||
const parsedSaveData = await this.getParsedSaveData(saveData);
|
||||
|
||||
const data: ImportData = {
|
||||
saveData: saveData,
|
||||
@@ -305,6 +357,20 @@ class BitburnerSaveObject implements BitburnerSaveObjectType {
|
||||
|
||||
const importedPlayer = loadPlayer(parsedSaveData.data.PlayerSave);
|
||||
|
||||
let syncSteamAchievements = true;
|
||||
// Parse data.SettingsSave to get syncSteamAchievements.
|
||||
if (parsedSaveData.data.SettingsSave && typeof parsedSaveData.data.SettingsSave === "string") {
|
||||
try {
|
||||
const settings: unknown = JSON.parse(parsedSaveData.data.SettingsSave);
|
||||
assertObject(settings);
|
||||
if (typeof settings.SyncSteamAchievements === "boolean") {
|
||||
syncSteamAchievements = settings.SyncSteamAchievements;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
const playerData: ImportPlayerData = {
|
||||
identifier: importedPlayer.identifier,
|
||||
lastSave: importedPlayer.lastSave,
|
||||
@@ -321,10 +387,12 @@ class BitburnerSaveObject implements BitburnerSaveObjectType {
|
||||
bitNodeLevel: importedPlayer.sourceFileLvl(Player.bitNodeN) + 1,
|
||||
sourceFiles: [...importedPlayer.sourceFiles].reduce<number>((total, [__bn, lvl]) => (total += lvl), 0),
|
||||
exploits: importedPlayer.exploits.length,
|
||||
|
||||
syncSteamAchievements,
|
||||
};
|
||||
|
||||
data.playerData = playerData;
|
||||
return Promise.resolve(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
toJSON(): IReviverValue {
|
||||
|
||||
@@ -190,6 +190,8 @@ export const Settings = {
|
||||
* src\utils\KeyBindingUtils.ts.
|
||||
*/
|
||||
KeyBindings: {} as PlayerDefinedKeyBindingsType,
|
||||
/** Whether to sync Steam achievements */
|
||||
SyncSteamAchievements: true,
|
||||
|
||||
load(saveString: string) {
|
||||
const save: unknown = JSON.parse(saveString);
|
||||
|
||||
@@ -16,7 +16,7 @@ export function ConfirmationModal(props: IProps): React.ReactElement {
|
||||
return (
|
||||
<Modal open={props.open} onClose={props.onClose}>
|
||||
<>
|
||||
<Typography>{props.confirmationText}</Typography>
|
||||
<Typography component={"div"}>{props.confirmationText}</Typography>
|
||||
<Button
|
||||
onClick={() => {
|
||||
props.onConfirm();
|
||||
|
||||
@@ -39,6 +39,7 @@ import { useBoolean } from "../hooks";
|
||||
import { ComparisonIcon } from "./ComparisonIcon";
|
||||
import { SaveData } from "../../../types";
|
||||
import { handleGetSaveDataInfoError } from "../../../utils/ErrorHandler";
|
||||
import { OptionSwitch } from "../OptionSwitch";
|
||||
|
||||
const useStyles = makeStyles()((theme: Theme) => ({
|
||||
root: {
|
||||
@@ -99,17 +100,24 @@ export const ImportSave = (props: { saveData: SaveData; automatic: boolean }): J
|
||||
const [isImportModalOpen, { on: openImportModal, off: closeImportModal }] = useBoolean(false);
|
||||
const [isSkillsExpanded, { toggle: toggleSkillsExpand }] = useBoolean(true);
|
||||
const [isOthersExpanded, { toggle: toggleOthersExpand }] = useBoolean(true);
|
||||
const [headback, setHeadback] = useState(false);
|
||||
const [headBack, setHeadBack] = useState(false);
|
||||
const [syncSteamAchievements, setSyncSteamAchievements] = useState(true);
|
||||
|
||||
const handleGoBack = (): void => {
|
||||
Settings.AutosaveInterval = initialAutosave;
|
||||
pushImportResult(false);
|
||||
Router.allowRouting(true);
|
||||
setHeadback(true);
|
||||
setHeadBack(true);
|
||||
};
|
||||
|
||||
const handleImport = async (): Promise<void> => {
|
||||
await saveObject.importGame(props.saveData, true);
|
||||
let overrideSettings = undefined;
|
||||
if (syncSteamAchievements !== importData?.playerData?.syncSteamAchievements) {
|
||||
overrideSettings = {
|
||||
SyncSteamAchievements: syncSteamAchievements,
|
||||
};
|
||||
}
|
||||
await saveObject.importGame(props.saveData, overrideSettings);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -120,8 +128,10 @@ export const ImportSave = (props: { saveData: SaveData; automatic: boolean }): J
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (headback) Router.toPage(Page.Terminal);
|
||||
}, [headback]);
|
||||
if (headBack) {
|
||||
Router.toPage(Page.Terminal);
|
||||
}
|
||||
}, [headBack]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData(): Promise<void> {
|
||||
@@ -130,8 +140,7 @@ export const ImportSave = (props: { saveData: SaveData; automatic: boolean }): J
|
||||
|
||||
setImportData(dataBeingImported);
|
||||
setCurrentData(dataCurrentlyInGame);
|
||||
|
||||
return Promise.resolve();
|
||||
setSyncSteamAchievements(dataBeingImported.playerData?.syncSteamAchievements ?? true);
|
||||
}
|
||||
if (props.saveData) {
|
||||
fetchData().catch((error) => {
|
||||
@@ -386,6 +395,19 @@ export const ImportSave = (props: { saveData: SaveData; automatic: boolean }): J
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<br />
|
||||
<OptionSwitch
|
||||
checked={syncSteamAchievements}
|
||||
onChange={(newValue) => setSyncSteamAchievements(newValue)}
|
||||
text="Sync Steam achievements"
|
||||
tooltip={
|
||||
<>
|
||||
This setting is only used in the Steam app. If this setting is enabled, the game will automatically sync
|
||||
your unlocked Steam achievements to Steam Cloud.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<ButtonGroup>
|
||||
<Tooltip title="Continue with current save">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FormControlLabel, Switch, Tooltip, Typography } from "@mui/material";
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
type OptionSwitchProps = {
|
||||
checked: boolean;
|
||||
@@ -24,6 +24,10 @@ export function OptionSwitch({
|
||||
onChange(newValue);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setValue(checked);
|
||||
}, [checked]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormControlLabel
|
||||
|
||||
Reference in New Issue
Block a user