diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index b2d6cb31..931f295e 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -131,6 +131,7 @@ "executable_path_in_use": "Executable already in use by \"{{game}}\"", "warning": "Warning:", "hydra_needs_to_remain_open": "for this download, Hydra needs to remain open util its conclusion. In case Hydra closes before the conclusion, you will lose your progress.", + "achievements": "Achievements {{unlockedCount}}/{{achievementsCount}}", "cloud_save": "Cloud save", "cloud_save_description": "Save your progress in the cloud and continue playing on any device", "backups": "Backups", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 5efeca20..5b811f7b 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -127,6 +127,7 @@ "executable_path_in_use": "Executável em uso por \"{{game}}\"", "warning": "Aviso:", "hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso.", + "achievements": "Conquistas {{unlockedCount}}/{{achievementsCount}}", "cloud_save": "Salvamento em nuvem", "cloud_save_description": "Matenha seu progresso na nuvem e continue de onde parou em qualquer dispositivo", "backups": "Backups", diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index 45d6af5e..fec0c366 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -115,7 +115,8 @@ "download": "Transferir", "executable_path_in_use": "Executável em uso por \"{{game}}\"", "warning": "Aviso:", - "hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso." + "hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso.", + "achievements": "Conquistas {{unlockedCount}}/{{achievementsCount}}" }, "activation": { "title": "Ativação", diff --git a/src/main/events/catalogue/get-game-achievements.ts b/src/main/events/catalogue/get-game-achievements.ts index f0fa2c94..8ab6d5d9 100644 --- a/src/main/events/catalogue/get-game-achievements.ts +++ b/src/main/events/catalogue/get-game-achievements.ts @@ -1,88 +1,74 @@ -import type { GameAchievement, GameShop } from "@types"; +import type { GameAchievement, GameShop, UnlockedAchievement } from "@types"; import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; import { gameAchievementRepository, - gameRepository, - userPreferencesRepository, + userAuthRepository, } from "@main/repository"; -import { UserNotLoggedInError } from "@shared"; -import { Game } from "@main/entity"; +import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data"; +import { HydraApi } from "@main/services"; -const getAchievementsDataFromApi = async ( - objectId: string, +const getAchievements = async ( shop: string, - game: Game | null + objectId: string, + userId?: string ) => { - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, + const userAuth = await userAuthRepository.findOne({ where: { userId } }); + + const cachedAchievements = await gameAchievementRepository.findOne({ + where: { objectId, shop }, }); - return HydraApi.get("/games/achievements", { - objectId, - shop, - language: userPreferences?.language || "en", - }) - .then((achievements) => { - if (game) { - gameAchievementRepository.upsert( - { - objectId, - shop, - achievements: JSON.stringify(achievements), - }, - ["objectId", "shop"] - ); - } + const achievementsData = cachedAchievements?.achievements + ? JSON.parse(cachedAchievements.achievements) + : await getGameAchievementData(objectId, shop); - return achievements; - }) - .catch((err) => { - if (err instanceof UserNotLoggedInError) throw err; - return []; - }); + if (!userId || userAuth) { + const unlockedAchievements = JSON.parse( + cachedAchievements?.unlockedAchievements || "[]" + ) as UnlockedAchievement[]; + + return { achievementsData, unlockedAchievements }; + } + + const unlockedAchievements = await HydraApi.get( + `/users/${userId}/games/achievements`, + { shop, objectId, language: "en" } + ); + + return { achievementsData, unlockedAchievements }; }; -const getGameAchievements = async ( - _event: Electron.IpcMainInvokeEvent, +export const getGameAchievements = async ( objectId: string, - shop: GameShop + shop: GameShop, + userId?: string ): Promise => { - const [game, cachedAchievements] = await Promise.all([ - gameRepository.findOne({ - where: { objectID: objectId, shop }, - }), - gameAchievementRepository.findOne({ where: { objectId, shop } }), - ]); + const { achievementsData, unlockedAchievements } = await getAchievements( + shop, + objectId, + userId + ); - const gameAchievements = cachedAchievements?.achievements - ? JSON.parse(cachedAchievements.achievements) - : await getAchievementsDataFromApi(objectId, shop, game); - - const unlockedAchievements = JSON.parse( - cachedAchievements?.unlockedAchievements || "[]" - ) as { name: string; unlockTime: number }[]; - - return gameAchievements - .map((achievement) => { + return achievementsData + .map((achievementData) => { const unlockedAchiement = unlockedAchievements.find( (localAchievement) => { return ( localAchievement.name.toUpperCase() == - achievement.name.toUpperCase() + achievementData.name.toUpperCase() ); } ); if (unlockedAchiement) { return { - ...achievement, + ...achievementData, unlocked: true, unlockTime: unlockedAchiement.unlockTime, }; } - return { ...achievement, unlocked: false, unlockTime: null }; + return { ...achievementData, unlocked: false, unlockTime: null }; }) .sort((a, b) => { if (a.unlocked && !b.unlocked) return -1; @@ -91,4 +77,13 @@ const getGameAchievements = async ( }); }; -registerEvent("getGameAchievements", getGameAchievements); +const getGameAchievementsEvent = async ( + _event: Electron.IpcMainInvokeEvent, + objectId: string, + shop: GameShop, + userId?: string +): Promise => { + return getGameAchievements(objectId, shop, userId); +}; + +registerEvent("getGameAchievements", getGameAchievementsEvent); diff --git a/src/main/events/cloud-save/download-game-artifact.ts b/src/main/events/cloud-save/download-game-artifact.ts index 3146aba7..b13a8137 100644 --- a/src/main/events/cloud-save/download-game-artifact.ts +++ b/src/main/events/cloud-save/download-game-artifact.ts @@ -20,11 +20,6 @@ export interface LudusaviBackup { }; } -const getPathDrive = (key: string) => { - const parts = key.split("/"); - return parts[0]; -}; - const replaceLudusaviBackupWithCurrentUser = ( backupPath: string, title: string diff --git a/src/main/events/library/add-game-to-library.ts b/src/main/events/library/add-game-to-library.ts index aa8aaab8..898c25cd 100644 --- a/src/main/events/library/add-game-to-library.ts +++ b/src/main/events/library/add-game-to-library.ts @@ -44,12 +44,12 @@ const addGameToLibrary = async ( }); } - updateLocalUnlockedAchivements(objectId); - const game = await gameRepository.findOne({ where: { objectID: objectId }, }); + updateLocalUnlockedAchivements(game!); + createGame(game!).catch(() => {}); }); }; diff --git a/src/main/helpers/index.ts b/src/main/helpers/index.ts index a9352dee..db5569c8 100644 --- a/src/main/helpers/index.ts +++ b/src/main/helpers/index.ts @@ -30,4 +30,4 @@ export const isPortableVersion = () => process.env.PORTABLE_EXECUTABLE_FILE !== null; export const normalizePath = (str: string) => - path.normalize(str).replace(/\\/g, "/"); + path.posix.normalize(str).replace(/\\/g, "/"); diff --git a/src/main/services/achievements/achievement-file-observer.ts b/src/main/services/achievements/achievement-file-observer.ts deleted file mode 100644 index d008304e..00000000 --- a/src/main/services/achievements/achievement-file-observer.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { parseAchievementFile } from "./parse-achievement-file"; -import { Game } from "@main/entity"; -import { mergeAchievements } from "./merge-achievements"; -import fs from "node:fs"; -import { - findAchievementFileInExecutableDirectory, - findAllAchievementFiles, -} from "./find-achivement-files"; -import type { AchievementFile } from "@types"; -import { logger } from "../logger"; - -const fileStats: Map = new Map(); - -const processAchievementFileDiff = async ( - game: Game, - file: AchievementFile -) => { - const unlockedAchievements = await parseAchievementFile( - file.filePath, - file.type - ); - - logger.log("Achievements from file", file.filePath, unlockedAchievements); - - if (unlockedAchievements.length) { - return mergeAchievements( - game.objectID, - game.shop, - unlockedAchievements, - true - ); - } -}; - -const compareFile = async (game: Game, file: AchievementFile) => { - try { - const stat = fs.statSync(file.filePath); - const currentFileStat = fileStats.get(file.filePath); - fileStats.set(file.filePath, stat.mtimeMs); - - if (!currentFileStat || currentFileStat === stat.mtimeMs) { - return; - } - - logger.log( - "Detected change in file", - file.filePath, - stat.mtimeMs, - fileStats.get(file.filePath) - ); - await processAchievementFileDiff(game, file); - } catch (err) { - fileStats.set(file.filePath, -1); - } -}; - -export const checkAchievementFileChange = async (games: Game[]) => { - const achievementFiles = await findAllAchievementFiles(); - - for (const game of games) { - const gameAchievementFiles = achievementFiles.get(game.objectID) || []; - const achievementFileInsideDirectory = - findAchievementFileInExecutableDirectory(game); - - if (achievementFileInsideDirectory) { - gameAchievementFiles.push(achievementFileInsideDirectory); - } - - if (!gameAchievementFiles.length) continue; - - logger.log( - "Achievements files to observe for:", - game.title, - gameAchievementFiles - ); - - for (const file of gameAchievementFiles) { - compareFile(game, file); - } - } -}; diff --git a/src/main/services/achievements/achievement-watcher.ts b/src/main/services/achievements/achievement-watcher.ts index d88771a8..166e6cff 100644 --- a/src/main/services/achievements/achievement-watcher.ts +++ b/src/main/services/achievements/achievement-watcher.ts @@ -1,5 +1,19 @@ import { gameRepository } from "@main/repository"; -import { checkAchievementFileChange as searchForAchievements } from "./achievement-file-observer"; +import { parseAchievementFile } from "./parse-achievement-file"; +import { Game } from "@main/entity"; +import { mergeAchievements } from "./merge-achievements"; +import fs, { readdirSync } from "node:fs"; +import { + findAchievementFileInExecutableDirectory, + findAllAchievementFiles, + getAlternativeObjectIds, +} from "./find-achivement-files"; +import type { AchievementFile } from "@types"; +import { achievementsLogger, logger } from "../logger"; +import { Cracker } from "@shared"; + +const fileStats: Map = new Map(); +const fltFiles: Map> = new Map(); export const watchAchievements = async () => { const games = await gameRepository.find({ @@ -10,5 +24,100 @@ export const watchAchievements = async () => { if (games.length === 0) return; - await searchForAchievements(games); + const achievementFiles = findAllAchievementFiles(); + + for (const game of games) { + for (const objectId of getAlternativeObjectIds(game.objectID)) { + const gameAchievementFiles = achievementFiles.get(objectId) || []; + const achievementFileInsideDirectory = + findAchievementFileInExecutableDirectory(game); + + gameAchievementFiles.push(...achievementFileInsideDirectory); + + if (!gameAchievementFiles.length) continue; + + console.log( + "Achievements files to observe for:", + game.title, + gameAchievementFiles + ); + + for (const file of gameAchievementFiles) { + compareFile(game, file); + } + } + } +}; + +const processAchievementFileDiff = async ( + game: Game, + file: AchievementFile +) => { + const unlockedAchievements = parseAchievementFile(file.filePath, file.type); + + logger.log("Achievements from file", file.filePath, unlockedAchievements); + + if (unlockedAchievements.length) { + return mergeAchievements( + game.objectID, + game.shop, + unlockedAchievements, + true + ); + } +}; + +const compareFltFolder = async (game: Game, file: AchievementFile) => { + try { + const currentAchievements = new Set(readdirSync(file.filePath)); + const previousAchievements = fltFiles.get(file.filePath); + + fltFiles.set(file.filePath, currentAchievements); + if ( + !previousAchievements || + currentAchievements.difference(previousAchievements).size === 0 + ) { + return; + } + + logger.log("Detected change in FLT folder", file.filePath); + await processAchievementFileDiff(game, file); + } catch (err) { + achievementsLogger.error(err); + fltFiles.set(file.filePath, new Set()); + } +}; + +const compareFile = async (game: Game, file: AchievementFile) => { + if (file.type === Cracker.flt) { + await compareFltFolder(game, file); + return; + } + + try { + const currentStat = fs.statSync(file.filePath); + const previousStat = fileStats.get(file.filePath); + fileStats.set(file.filePath, currentStat.mtimeMs); + + if (!previousStat) { + if (currentStat.mtimeMs) { + await processAchievementFileDiff(game, file); + return; + } + } + + if (previousStat === currentStat.mtimeMs) { + return; + } + + logger.log( + "Detected change in file", + file.filePath, + currentStat.mtimeMs, + fileStats.get(file.filePath) + ); + await processAchievementFileDiff(game, file); + } catch (err) { + fileStats.set(file.filePath, -1); + } }; diff --git a/src/main/services/achievements/find-achivement-files.ts b/src/main/services/achievements/find-achivement-files.ts index 0b694578..917e5351 100644 --- a/src/main/services/achievements/find-achivement-files.ts +++ b/src/main/services/achievements/find-achivement-files.ts @@ -24,9 +24,10 @@ const crackers = [ Cracker.skidrow, Cracker.smartSteamEmu, Cracker.empress, + Cracker.flt, ]; -const getPathFromCracker = async (cracker: Cracker) => { +const getPathFromCracker = (cracker: Cracker) => { if (cracker === Cracker.codex) { return [ { @@ -85,6 +86,10 @@ const getPathFromCracker = async (cracker: Cracker) => { folderPath: path.join(programData, "Steam", "Player"), fileLocation: ["stats", "achievements.ini"], }, + { + folderPath: path.join(programData, "Steam", "dodi"), + fileLocation: ["stats", "achievements.ini"], + }, ]; } @@ -131,7 +136,33 @@ const getPathFromCracker = async (cracker: Cracker) => { return [ { folderPath: path.join(appData, "SmartSteamEmu"), - fileLocation: ["User", "Achievements"], + fileLocation: ["User", "Achievements.ini"], + }, + ]; + } + + if (cracker === Cracker._3dm) { + return []; + } + + if (cracker === Cracker.flt) { + return [ + { + folderPath: path.join(appData, "FLT"), + fileLocation: ["stats"], + }, + ]; + } + + if (cracker == Cracker.rle) { + return [ + { + folderPath: path.join(appData, "RLE"), + fileLocation: ["achievements.ini"], + }, + { + folderPath: path.join(appData, "RLE"), + fileLocation: ["Achievements.ini"], }, ]; } @@ -140,20 +171,29 @@ const getPathFromCracker = async (cracker: Cracker) => { throw new Error(`Cracker ${cracker} not implemented`); }; -export const findAchievementFiles = async (game: Game) => { +export const getAlternativeObjectIds = (objectId: string) => { + // Dishonored + if (objectId === "205100") { + return ["205100", "217980", "31292"]; + } + + return [objectId]; +}; + +export const findAchievementFiles = (game: Game) => { const achievementFiles: AchievementFile[] = []; for (const cracker of crackers) { - for (const { folderPath, fileLocation } of await getPathFromCracker( - cracker - )) { - const filePath = path.join(folderPath, game.objectID, ...fileLocation); + for (const { folderPath, fileLocation } of getPathFromCracker(cracker)) { + for (const objectId of getAlternativeObjectIds(game.objectID)) { + const filePath = path.join(folderPath, objectId, ...fileLocation); - if (fs.existsSync(filePath)) { - achievementFiles.push({ - type: cracker, - filePath, - }); + if (fs.existsSync(filePath)) { + achievementFiles.push({ + type: cracker, + filePath, + }); + } } } } @@ -163,31 +203,40 @@ export const findAchievementFiles = async (game: Game) => { export const findAchievementFileInExecutableDirectory = ( game: Game -): AchievementFile | null => { +): AchievementFile[] => { if (!game.executablePath) { - return null; + return []; } - const steamDataPath = path.join( - game.executablePath, - "..", - "SteamData", - "user_stats.ini" - ); - - return { - type: Cracker.userstats, - filePath: steamDataPath, - }; + return [ + { + type: Cracker.userstats, + filePath: path.join( + game.executablePath, + "..", + "SteamData", + "user_stats.ini" + ), + }, + { + type: Cracker._3dm, + filePath: path.join( + game.executablePath, + "..", + "3DMGAME", + "Player", + "stats", + "achievements.ini" + ), + }, + ]; }; -export const findAllAchievementFiles = async () => { +export const findAllAchievementFiles = () => { const gameAchievementFiles = new Map(); for (const cracker of crackers) { - for (const { folderPath, fileLocation } of await getPathFromCracker( - cracker - )) { + for (const { folderPath, fileLocation } of getPathFromCracker(cracker)) { if (!fs.existsSync(folderPath)) { continue; } diff --git a/src/main/services/achievements/get-game-achievement-data.ts b/src/main/services/achievements/get-game-achievement-data.ts index 79e551ac..7f1e6b5a 100644 --- a/src/main/services/achievements/get-game-achievement-data.ts +++ b/src/main/services/achievements/get-game-achievement-data.ts @@ -1,4 +1,7 @@ -import { userPreferencesRepository } from "@main/repository"; +import { + gameAchievementRepository, + userPreferencesRepository, +} from "@main/repository"; import { HydraApi } from "../hydra-api"; export const getGameAchievementData = async ( @@ -13,5 +16,18 @@ export const getGameAchievementData = async ( shop, objectId, language: userPreferences?.language || "en", - }); + }) + .then(async (achievements) => { + await gameAchievementRepository.upsert( + { + objectId, + shop, + achievements: JSON.stringify(achievements), + }, + ["objectId", "shop"] + ); + + return achievements; + }) + .catch(() => []); }; diff --git a/src/main/services/achievements/merge-achievements.ts b/src/main/services/achievements/merge-achievements.ts index dd4bd465..6438a413 100644 --- a/src/main/services/achievements/merge-achievements.ts +++ b/src/main/services/achievements/merge-achievements.ts @@ -2,6 +2,7 @@ import { gameAchievementRepository, gameRepository } from "@main/repository"; import type { GameShop, UnlockedAchievement } from "@types"; import { WindowManager } from "../window-manager"; import { HydraApi } from "../hydra-api"; +import { getGameAchievements } from "@main/events/catalogue/get-game-achievements"; const saveAchievementsOnLocal = async ( objectId: string, @@ -17,11 +18,10 @@ const saveAchievementsOnLocal = async ( }, ["objectId", "shop"] ) - .then(() => { + .then(async () => { WindowManager.mainWindow?.webContents.send( - "on-achievement-unlocked", - objectId, - shop + `on-update-achievements-${objectId}-${shop}`, + await getGameAchievements(objectId, shop as GameShop) ); }); }; @@ -47,7 +47,7 @@ export const mergeAchievements = async ( const unlockedAchievements = JSON.parse( localGameAchievement?.unlockedAchievements || "[]" - ); + ).filter((achievement) => achievement.name); const newAchievements = achievements .filter((achievement) => { @@ -60,7 +60,7 @@ export const mergeAchievements = async ( .map((achievement) => { return { name: achievement.name.toUpperCase(), - unlockTime: achievement.unlockTime * 1000, + unlockTime: achievement.unlockTime, }; }); diff --git a/src/main/services/achievements/parse-achievement-file.ts b/src/main/services/achievements/parse-achievement-file.ts index e239c05e..47c8e805 100644 --- a/src/main/services/achievements/parse-achievement-file.ts +++ b/src/main/services/achievements/parse-achievement-file.ts @@ -1,67 +1,84 @@ import { Cracker } from "@shared"; import { UnlockedAchievement } from "@types"; -import { existsSync, createReadStream, readFileSync } from "node:fs"; -import readline from "node:readline"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import { achievementsLogger } from "../logger"; -export const parseAchievementFile = async ( +export const parseAchievementFile = ( filePath: string, type: Cracker -): Promise => { +): UnlockedAchievement[] => { if (!existsSync(filePath)) return []; if (type == Cracker.codex) { - const parsed = await iniParse(filePath); + const parsed = iniParse(filePath); return processDefault(parsed); } if (type == Cracker.rune) { - const parsed = await iniParse(filePath); + const parsed = iniParse(filePath); return processDefault(parsed); } if (type === Cracker.onlineFix) { - const parsed = await iniParse(filePath); + const parsed = iniParse(filePath); return processOnlineFix(parsed); } if (type === Cracker.goldberg) { - const parsed = await jsonParse(filePath); + const parsed = jsonParse(filePath); return processGoldberg(parsed); } if (type == Cracker.userstats) { - const parsed = await iniParse(filePath); + const parsed = iniParse(filePath); return processUserStats(parsed); } if (type == Cracker.rld) { - const parsed = await iniParse(filePath); + const parsed = iniParse(filePath); return processRld(parsed); } if (type === Cracker.skidrow) { - const parsed = await iniParse(filePath); + const parsed = iniParse(filePath); return processSkidrow(parsed); } - achievementsLogger.log(`${type} achievements found on ${filePath}`); + if (type === Cracker._3dm) { + const parsed = iniParse(filePath); + return process3DM(parsed); + } + + if (type === Cracker.flt) { + const achievements = readdirSync(filePath); + + return achievements.map((achievement) => { + return { + name: achievement, + unlockTime: Date.now(), + }; + }); + } + + if (type === Cracker.creamAPI) { + const parsed = iniParse(filePath); + return processCreamAPI(parsed); + } + + achievementsLogger.log( + `Unprocessed ${type} achievements found on ${filePath}` + ); return []; }; -const iniParse = async (filePath: string) => { +const iniParse = (filePath: string) => { try { - const file = createReadStream(filePath); - - const lines = readline.createInterface({ - input: file, - crlfDelay: Infinity, - }); + const lines = readFileSync(filePath, "utf-8").split(/[\r\n]+/); let objectName = ""; const object: Record> = {}; - for await (const line of lines) { + for (const line of lines) { if (line.startsWith("###") || !line.length) continue; if (line.startsWith("[") && line.endsWith("]")) { @@ -69,13 +86,13 @@ const iniParse = async (filePath: string) => { object[objectName] = {}; } else { const [name, ...value] = line.split("="); - object[objectName][name.trim()] = value.join("").trim(); + object[objectName][name.trim()] = value.join("=").trim(); } } - console.log("Parsed ini", object); return object; - } catch { + } catch (err) { + achievementsLogger.error(`Error parsing ${filePath}`, err); return null; } }; @@ -83,7 +100,8 @@ const iniParse = async (filePath: string) => { const jsonParse = (filePath: string) => { try { return JSON.parse(readFileSync(filePath, "utf-8")); - } catch { + } catch (err) { + achievementsLogger.error(`Error parsing ${filePath}`, err); return null; } }; @@ -97,7 +115,28 @@ const processOnlineFix = (unlockedAchievements: any): UnlockedAchievement[] => { if (unlockedAchievement?.achieved) { parsedUnlockedAchievements.push({ name: achievement, - unlockTime: unlockedAchievement.timestamp, + unlockTime: unlockedAchievement.timestamp * 1000, + }); + } + } + + return parsedUnlockedAchievements; +}; + +const processCreamAPI = (unlockedAchievements: any): UnlockedAchievement[] => { + const parsedUnlockedAchievements: UnlockedAchievement[] = []; + + for (const achievement of Object.keys(unlockedAchievements)) { + const unlockedAchievement = unlockedAchievements[achievement]; + + if (unlockedAchievement?.achieved) { + const unlockTime = unlockedAchievement.unlocktime; + parsedUnlockedAchievements.push({ + name: achievement, + unlockTime: + unlockTime.length === 7 + ? unlockTime * 1000 * 1000 + : unlockTime * 1000, }); } } @@ -115,7 +154,7 @@ const processSkidrow = (unlockedAchievements: any): UnlockedAchievement[] => { if (unlockedAchievement[0] === "1") { parsedUnlockedAchievements.push({ name: achievement, - unlockTime: unlockedAchievement[unlockedAchievement.length - 1], + unlockTime: unlockedAchievement[unlockedAchievement.length - 1] * 1000, }); } } @@ -132,13 +171,36 @@ const processGoldberg = (unlockedAchievements: any): UnlockedAchievement[] => { if (unlockedAchievement?.earned) { newUnlockedAchievements.push({ name: achievement, - unlockTime: unlockedAchievement.earned_time, + unlockTime: unlockedAchievement.earned_time * 1000, }); } } return newUnlockedAchievements; }; +const process3DM = (unlockedAchievements: any): UnlockedAchievement[] => { + const newUnlockedAchievements: UnlockedAchievement[] = []; + + const achievements = unlockedAchievements["State"]; + const times = unlockedAchievements["Time"]; + + for (const achievement of Object.keys(achievements)) { + if (achievements[achievement] == "0101") { + const time = times[achievement]; + + newUnlockedAchievements.push({ + name: achievement, + unlockTime: + new DataView( + new Uint8Array(Buffer.from(time.toString(), "hex")).buffer + ).getUint32(0, true) * 1000, + }); + } + } + + return newUnlockedAchievements; +}; + const processDefault = (unlockedAchievements: any): UnlockedAchievement[] => { const newUnlockedAchievements: UnlockedAchievement[] = []; @@ -148,7 +210,7 @@ const processDefault = (unlockedAchievements: any): UnlockedAchievement[] => { if (unlockedAchievement?.Achieved) { newUnlockedAchievements.push({ name: achievement, - unlockTime: unlockedAchievement.UnlockTime, + unlockTime: unlockedAchievement.UnlockTime * 1000, }); } } @@ -167,11 +229,12 @@ const processRld = (unlockedAchievements: any): UnlockedAchievement[] => { if (unlockedAchievement?.State) { newUnlockedAchievements.push({ name: achievement, - unlockTime: new DataView( - new Uint8Array( - Buffer.from(unlockedAchievement.Time.toString(), "hex") - ).buffer - ).getUint32(0, true), + unlockTime: + new DataView( + new Uint8Array( + Buffer.from(unlockedAchievement.Time.toString(), "hex") + ).buffer + ).getUint32(0, true) * 1000, }); } } @@ -195,8 +258,8 @@ const processUserStats = (unlockedAchievements: any): UnlockedAchievement[] => { if (!isNaN(unlockTime)) { newUnlockedAchievements.push({ - name: achievement, - unlockTime: unlockTime, + name: achievement.replace(/"/g, ``), + unlockTime: unlockTime * 1000, }); } } diff --git a/src/main/services/achievements/update-local-unlocked-achivements.ts b/src/main/services/achievements/update-local-unlocked-achivements.ts index 6c0fed8f..13f33fcd 100644 --- a/src/main/services/achievements/update-local-unlocked-achivements.ts +++ b/src/main/services/achievements/update-local-unlocked-achivements.ts @@ -2,96 +2,82 @@ import { gameAchievementRepository, gameRepository } from "@main/repository"; import { findAllAchievementFiles, findAchievementFiles, + findAchievementFileInExecutableDirectory, + getAlternativeObjectIds, } from "./find-achivement-files"; import { parseAchievementFile } from "./parse-achievement-file"; import { mergeAchievements } from "./merge-achievements"; import type { UnlockedAchievement } from "@types"; import { getGameAchievementData } from "./get-game-achievement-data"; +import { achievementsLogger } from "../logger"; +import { Game } from "@main/entity"; export const updateAllLocalUnlockedAchievements = async () => { - const gameAchievementFilesMap = await findAllAchievementFiles(); + const gameAchievementFilesMap = findAllAchievementFiles(); - for (const objectId of gameAchievementFilesMap.keys()) { - const gameAchievementFiles = gameAchievementFilesMap.get(objectId)!; + const games = await gameRepository.find({ + where: { + isDeleted: false, + }, + }); - const [game, localAchievements] = await Promise.all([ - gameRepository.findOne({ - where: { objectID: objectId, shop: "steam", isDeleted: false }, - }), - gameAchievementRepository.findOne({ - where: { objectId, shop: "steam" }, - }), - ]); + for (const game of games) { + for (const objectId of getAlternativeObjectIds(game.objectID)) { + const gameAchievementFiles = gameAchievementFilesMap.get(objectId) || []; + const achievementFileInsideDirectory = + findAchievementFileInExecutableDirectory(game); - if (!game) continue; + gameAchievementFiles.push(...achievementFileInsideDirectory); - if (!localAchievements || !localAchievements.achievements) { - await getGameAchievementData(objectId, "steam") - .then((achievements) => { - return gameAchievementRepository.upsert( - { - objectId, - shop: "steam", - achievements: JSON.stringify(achievements), - }, - ["objectId", "shop"] - ); + gameAchievementRepository + .findOne({ + where: { objectId: game.objectID, shop: "steam" }, }) - .catch(() => {}); - } + .then((localAchievements) => { + if (!localAchievements || !localAchievements.achievements) { + getGameAchievementData(game.objectID, "steam"); + } + }); - const unlockedAchievements: UnlockedAchievement[] = []; + const unlockedAchievements: UnlockedAchievement[] = []; - for (const achievementFile of gameAchievementFiles) { - const parsedAchievements = await parseAchievementFile( - achievementFile.filePath, - achievementFile.type - ); - console.log("Parsed for", game.title, parsedAchievements); - if (parsedAchievements.length) { - unlockedAchievements.push(...parsedAchievements); + for (const achievementFile of gameAchievementFiles) { + const parsedAchievements = parseAchievementFile( + achievementFile.filePath, + achievementFile.type + ); + + if (parsedAchievements.length) { + unlockedAchievements.push(...parsedAchievements); + } + + achievementsLogger.log( + "Achievement file for", + game.title, + achievementFile.filePath, + parsedAchievements + ); } - } - mergeAchievements(objectId, "steam", unlockedAchievements, false); + mergeAchievements(game.objectID, "steam", unlockedAchievements, false); + } } }; -export const updateLocalUnlockedAchivements = async (objectId: string) => { - const [game, localAchievements] = await Promise.all([ - gameRepository.findOne({ - where: { objectID: objectId, shop: "steam", isDeleted: false }, - }), - gameAchievementRepository.findOne({ - where: { objectId, shop: "steam" }, - }), - ]); +export const updateLocalUnlockedAchivements = async (game: Game) => { + const gameAchievementFiles = findAchievementFiles(game); - if (!game) return; + const achievementFileInsideDirectory = + findAchievementFileInExecutableDirectory(game); - const gameAchievementFiles = await findAchievementFiles(game); + gameAchievementFiles.push(...achievementFileInsideDirectory); console.log("Achievements files for", game.title, gameAchievementFiles); - if (!localAchievements || !localAchievements.achievements) { - await getGameAchievementData(objectId, "steam") - .then((achievements) => { - return gameAchievementRepository.upsert( - { - objectId, - shop: "steam", - achievements: JSON.stringify(achievements), - }, - ["objectId", "shop"] - ); - }) - .catch(() => {}); - } - const unlockedAchievements: UnlockedAchievement[] = []; for (const achievementFile of gameAchievementFiles) { - const localAchievementFile = await parseAchievementFile( + const localAchievementFile = parseAchievementFile( achievementFile.filePath, achievementFile.type ); @@ -101,5 +87,5 @@ export const updateLocalUnlockedAchivements = async (objectId: string) => { } } - mergeAchievements(objectId, "steam", unlockedAchievements, false); + mergeAchievements(game.objectID, "steam", unlockedAchievements, false); }; diff --git a/src/main/services/logger.ts b/src/main/services/logger.ts index 26a8331c..e3e52290 100644 --- a/src/main/services/logger.ts +++ b/src/main/services/logger.ts @@ -10,6 +10,10 @@ log.transports.file.resolvePathFn = ( return path.join(logsPath, "pythoninstance.txt"); } + if (message?.scope == "achievements") { + return path.join(logsPath, "achievements.txt"); + } + if (message?.level === "error") { return path.join(logsPath, "error.txt"); } diff --git a/src/main/services/main-loop.ts b/src/main/services/main-loop.ts index f45956f2..5ba57fc3 100644 --- a/src/main/services/main-loop.ts +++ b/src/main/services/main-loop.ts @@ -12,6 +12,6 @@ export const startMainLoop = async () => { watchAchievements(), ]); - await sleep(1000); + await sleep(1500); } }; diff --git a/src/preload/index.ts b/src/preload/index.ts index decd407c..40a6c101 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -14,6 +14,7 @@ import type { } from "@types"; import type { CatalogueCategory } from "@shared"; import type { AxiosProgressEvent } from "axios"; +import { GameAchievement } from "@main/entity"; contextBridge.exposeInMainWorld("electron", { /* Torrenting */ @@ -50,8 +51,8 @@ contextBridge.exposeInMainWorld("electron", { getGameStats: (objectId: string, shop: GameShop) => ipcRenderer.invoke("getGameStats", objectId, shop), getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"), - getGameAchievements: (objectId: string, shop: GameShop) => - ipcRenderer.invoke("getGameAchievements", objectId, shop), + getGameAchievements: (objectId: string, shop: GameShop, userId?: string) => + ipcRenderer.invoke("getGameAchievements", objectId, shop, userId), onAchievementUnlocked: ( cb: ( objectId: string, @@ -69,6 +70,22 @@ contextBridge.exposeInMainWorld("electron", { return () => ipcRenderer.removeListener("on-achievement-unlocked", listener); }, + onUpdateAchievements: ( + objectId: string, + shop: GameShop, + cb: (achievements: GameAchievement[]) => void + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + achievements: GameAchievement[] + ) => cb(achievements); + ipcRenderer.on(`on-update-achievements-${objectId}-${shop}`, listener); + return () => + ipcRenderer.removeListener( + `on-update-achievements-${objectId}-${shop}`, + listener + ); + }, /* User preferences */ getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"), diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx index 2a819e1b..7ff8bb09 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -132,13 +132,14 @@ export function GameDetailsContextProvider({ setIsLoading(false); }); - window.electron.getGameStats(objectId!, shop as GameShop).then((result) => { + window.electron.getGameStats(objectId, shop as GameShop).then((result) => { setStats(result); }); window.electron - .getGameAchievements(objectId!, shop as GameShop) + .getGameAchievements(objectId, shop as GameShop) .then((achievements) => { + // TODO: race condition setAchievements(achievements); }) .catch(() => { @@ -175,14 +176,11 @@ export function GameDetailsContextProvider({ }, [game?.id, isGameRunning, updateGame]); useEffect(() => { - const unsubscribe = window.electron.onAchievementUnlocked( - (objectId, shop) => { - if (objectId !== objectId || shop !== shop) return; - - window.electron - .getGameAchievements(objectId!, shop as GameShop) - .then(setAchievements) - .catch(() => {}); + const unsubscribe = window.electron.onUpdateAchievements( + objectId, + shop, + (achievements) => { + setAchievements(achievements); } ); diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 2896abf3..677c3ee2 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -66,7 +66,8 @@ declare global { getTrendingGames: () => Promise; getGameAchievements: ( objectId: string, - shop: GameShop + shop: GameShop, + userId?: string ) => Promise; onAchievementUnlocked: ( cb: ( @@ -75,6 +76,11 @@ declare global { achievements?: { displayName: string; iconUrl: string }[] ) => void ) => () => Electron.IpcRenderer; + onUpdateAchievements: ( + objectId: string, + shop: GameShop, + cb: (achievements: GameAchievement[]) => void + ) => () => Electron.IpcRenderer; /* Library */ addGameToLibrary: ( diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 9ee0588b..5f91b424 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -28,10 +28,11 @@ import { import { store } from "./store"; import resources from "@locales"; -import { Achievement } from "./pages/achievement/achievement"; +import { AchievementNotification } from "./pages/achievement/notification/achievement-notification"; import "./workers"; import { RepacksContextProvider } from "./context"; +import { Achievement } from "./pages/achievement/achievements"; Sentry.init({}); @@ -69,8 +70,12 @@ ReactDOM.createRoot(document.getElementById("root")!).render( + - + diff --git a/src/renderer/src/pages/achievement/achievements.tsx b/src/renderer/src/pages/achievement/achievements.tsx new file mode 100644 index 00000000..17917045 --- /dev/null +++ b/src/renderer/src/pages/achievement/achievements.tsx @@ -0,0 +1,71 @@ +import { useDate } from "@renderer/hooks"; +import { SPACING_UNIT } from "@renderer/theme.css"; +import { GameAchievement, GameShop } from "@types"; +import { useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; + +export function Achievement() { + const [searchParams] = useSearchParams(); + const objectId = searchParams.get("objectId"); + const shop = searchParams.get("shop"); + const userId = searchParams.get("userId"); + + const { format } = useDate(); + + const [achievements, setAchievements] = useState([]); + + useEffect(() => { + if (objectId && shop) { + window.electron + .getGameAchievements(objectId, shop as GameShop, userId || undefined) + .then((achievements) => { + setAchievements(achievements); + }); + } + }, [objectId, shop, userId]); + + return ( +
+

Achievement

+ +
+ {achievements.map((achievement, index) => ( +
+ {achievement.displayName} +
+

{achievement.displayName}

+ {achievement.unlockTime && format(achievement.unlockTime)} +
+
+ ))} +
+
+ ); +} diff --git a/src/renderer/src/pages/achievement/achievement.css.ts b/src/renderer/src/pages/achievement/notification/achievement-notification.css.ts similarity index 95% rename from src/renderer/src/pages/achievement/achievement.css.ts rename to src/renderer/src/pages/achievement/notification/achievement-notification.css.ts index d5cb180b..ba9469bb 100644 --- a/src/renderer/src/pages/achievement/achievement.css.ts +++ b/src/renderer/src/pages/achievement/notification/achievement-notification.css.ts @@ -1,5 +1,5 @@ import { recipe } from "@vanilla-extract/recipes"; -import { vars } from "../../theme.css"; +import { vars } from "../../../theme.css"; import { keyframes, style } from "@vanilla-extract/css"; const animationIn = keyframes({ diff --git a/src/renderer/src/pages/achievement/achievement.tsx b/src/renderer/src/pages/achievement/notification/achievement-notification.tsx similarity index 96% rename from src/renderer/src/pages/achievement/achievement.tsx rename to src/renderer/src/pages/achievement/notification/achievement-notification.tsx index f78d8f49..d33cb231 100644 --- a/src/renderer/src/pages/achievement/achievement.tsx +++ b/src/renderer/src/pages/achievement/notification/achievement-notification.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import achievementSound from "@renderer/assets/audio/achievement.wav"; import { useTranslation } from "react-i18next"; -import * as styles from "./achievement.css"; +import * as styles from "./achievement-notification.css"; interface AchievementInfo { displayName: string; @@ -10,7 +10,7 @@ interface AchievementInfo { const NOTIFICATION_TIMEOUT = 4000; -export function Achievement() { +export function AchievementNotification() { const { t } = useTranslation("achievement"); const [isClosing, setIsClosing] = useState(false); diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx index 5c3cbbc0..6c6dca33 100644 --- a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx +++ b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx @@ -1,7 +1,7 @@ import { useContext, useEffect, useState } from "react"; import type { HowLongToBeatCategory, SteamAppDetails } from "@types"; import { useTranslation } from "react-i18next"; -import { Button } from "@renderer/components"; +import { Button, Link } from "@renderer/components"; import * as styles from "./sidebar.css"; import { gameDetailsContext } from "@renderer/context"; @@ -29,6 +29,11 @@ export function Sidebar() { const { numberFormatter } = useFormat(); + const buildGameAchievementPath = () => { + const urlParams = new URLSearchParams({ objectId: objectId!, shop }); + return `/achievements?${urlParams.toString()}`; + }; + useEffect(() => { if (objectId) { setHowLongToBeat({ isLoading: true, data: null }); @@ -69,44 +74,56 @@ export function Sidebar() { return (