UI: Add option to enable/disable syncing Steam achievements (#2117)

This commit is contained in:
catloversg
2025-05-10 16:13:41 +07:00
committed by GitHub
parent 290557332c
commit 494ef0dff3
13 changed files with 197 additions and 64 deletions

View File

@@ -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,
};

View File

@@ -59,7 +59,7 @@ async function createWindow(killall) {
window.webContents.backgroundThrottling = false;
achievements.enableAchievementsInterval(window);
achievements.enableSyncingAchievements();
utils.attachUnresponsiveAppHandler(window);
menu.refreshMenu(window);

View File

@@ -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();

View File

@@ -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);

View File

@@ -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;
}),
);
}
}

View File

@@ -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 });
}

View File

@@ -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 />
</>
}
/>

View File

@@ -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>
);
};

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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();

View File

@@ -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">

View File

@@ -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