diff --git a/electron/achievements.js b/electron/achievements.js index 440275178..8a92a180b 100644 --- a/electron/achievements.js +++ b/electron/achievements.js @@ -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, }; diff --git a/electron/gameWindow.js b/electron/gameWindow.js index 55167ed58..947eedfd2 100644 --- a/electron/gameWindow.js +++ b/electron/gameWindow.js @@ -59,7 +59,7 @@ async function createWindow(killall) { window.webContents.backgroundThrottling = false; - achievements.enableAchievementsInterval(window); + achievements.enableSyncingAchievements(); utils.attachUnresponsiveAppHandler(window); menu.refreshMenu(window); diff --git a/electron/main.js b/electron/main.js index 82c10728c..34bf1cdc9 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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(); diff --git a/electron/preload.js b/electron/preload.js index 7f2733ad1..3ef521b3b 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -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); diff --git a/src/Achievements/Achievements.ts b/src/Achievements/Achievements.ts index 5526d26fd..0b20fb2a1 100644 --- a/src/Achievements/Achievements.ts +++ b/src/Achievements/Achievements.ts @@ -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 = ((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; + }), + ); + } } diff --git a/src/Electron.tsx b/src/Electron.tsx index 54c02659a..ab37bbea0 100644 --- a/src/Electron.tsx +++ b/src/Electron.tsx @@ -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 }); +} diff --git a/src/GameOptions/ui/GameOptionsSidebar.tsx b/src/GameOptions/ui/GameOptionsSidebar.tsx index 19f9416f0..bc3e75fce 100644 --- a/src/GameOptions/ui/GameOptionsSidebar.tsx +++ b/src/GameOptions/ui/GameOptionsSidebar.tsx @@ -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(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 => { )}

+ 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. + + } + /> +
} /> diff --git a/src/GameOptions/ui/MiscPage.tsx b/src/GameOptions/ui/MiscPage.tsx index 703d8b651..70a426554 100644 --- a/src/GameOptions/ui/MiscPage.tsx +++ b/src/GameOptions/ui/MiscPage.tsx @@ -54,6 +54,17 @@ export const MiscPage = (): React.ReactElement => { } /> + (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. + + } + /> ); }; diff --git a/src/SaveObject.ts b/src/SaveObject.ts index 29b320829..b05839be4 100644 --- a/src/SaveObject.ts +++ b/src/SaveObject.ts @@ -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 { + async importGame( + saveData: SaveData, + overrideSettings?: { + SyncSteamAchievements: boolean; + }, + ): Promise { 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 { - 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 { - if (!saveData || saveData.length === 0) throw new Error("Invalid save data"); + async getParsedSaveData(saveData: SaveData): Promise { + 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 { + 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((total, [__bn, lvl]) => (total += lvl), 0), exploits: importedPlayer.exploits.length, + + syncSteamAchievements, }; data.playerData = playerData; - return Promise.resolve(data); + return data; } toJSON(): IReviverValue { diff --git a/src/Settings/Settings.ts b/src/Settings/Settings.ts index b8e6240ae..8fa3d7974 100644 --- a/src/Settings/Settings.ts +++ b/src/Settings/Settings.ts @@ -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); diff --git a/src/ui/React/ConfirmationModal.tsx b/src/ui/React/ConfirmationModal.tsx index b4de623d3..3d0ebaca4 100644 --- a/src/ui/React/ConfirmationModal.tsx +++ b/src/ui/React/ConfirmationModal.tsx @@ -16,7 +16,7 @@ export function ConfirmationModal(props: IProps): React.ReactElement { return ( <> - {props.confirmationText} + {props.confirmationText}