mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
feature: wip-game-achievements
refactor: rename files
This commit is contained in:
parent
fabeedaa8a
commit
8fb62af0cf
18 changed files with 739 additions and 0 deletions
|
@ -7,6 +7,7 @@ import {
|
||||||
Repack,
|
Repack,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
UserAuth,
|
UserAuth,
|
||||||
|
GameAchievement,
|
||||||
} from "@main/entity";
|
} from "@main/entity";
|
||||||
|
|
||||||
import { databasePath } from "./constants";
|
import { databasePath } from "./constants";
|
||||||
|
@ -21,6 +22,7 @@ export const dataSource = new DataSource({
|
||||||
DownloadSource,
|
DownloadSource,
|
||||||
DownloadQueue,
|
DownloadQueue,
|
||||||
UserAuth,
|
UserAuth,
|
||||||
|
GameAchievement,
|
||||||
],
|
],
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
database: databasePath,
|
database: databasePath,
|
||||||
|
|
21
src/main/entity/game-achievements.entity.ts
Normal file
21
src/main/entity/game-achievements.entity.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
OneToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from "typeorm";
|
||||||
|
import { Game } from "./game.entity";
|
||||||
|
|
||||||
|
@Entity("game_achievement")
|
||||||
|
export class GameAchievement {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@OneToOne(() => Game)
|
||||||
|
@JoinColumn()
|
||||||
|
game: Game;
|
||||||
|
|
||||||
|
@Column("text", { nullable: true })
|
||||||
|
achievements: string;
|
||||||
|
}
|
|
@ -2,6 +2,8 @@ export * from "./game.entity";
|
||||||
export * from "./repack.entity";
|
export * from "./repack.entity";
|
||||||
export * from "./user-preferences.entity";
|
export * from "./user-preferences.entity";
|
||||||
export * from "./game-shop-cache.entity";
|
export * from "./game-shop-cache.entity";
|
||||||
|
export * from "./game.entity";
|
||||||
|
export * from "./game-achievements.entity";
|
||||||
export * from "./download-source.entity";
|
export * from "./download-source.entity";
|
||||||
export * from "./download-queue.entity";
|
export * from "./download-queue.entity";
|
||||||
export * from "./user-auth";
|
export * from "./user-auth";
|
||||||
|
|
89
src/main/events/achievements/game-achievements-observer.ts
Normal file
89
src/main/events/achievements/game-achievements-observer.ts
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
import { watch } from "node:fs/promises";
|
||||||
|
import { getGameAchievementsToWatch } from "./services/get-game-achievements-to-watch";
|
||||||
|
import { checkUnlockedAchievements } from "./util/check-unlocked-achievements";
|
||||||
|
import { parseAchievementFile } from "./util/parseAchievementFile";
|
||||||
|
import { gameAchievementRepository } from "@main/repository";
|
||||||
|
|
||||||
|
type GameAchievementObserver = {
|
||||||
|
[id: number]: AbortController | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const gameAchievementObserver: GameAchievementObserver = {};
|
||||||
|
|
||||||
|
export const startGameAchievementObserver = async (gameId: number) => {
|
||||||
|
if (
|
||||||
|
gameAchievementObserver[gameId] === null ||
|
||||||
|
gameAchievementObserver[gameId]
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Starting: ${gameId}`);
|
||||||
|
|
||||||
|
const achievementsToWatch = await getGameAchievementsToWatch(gameId);
|
||||||
|
|
||||||
|
if (!achievementsToWatch) {
|
||||||
|
console.log("No achievements to observe");
|
||||||
|
gameAchievementObserver[gameId] = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { steamId, checkedAchievements, achievementFiles } =
|
||||||
|
achievementsToWatch;
|
||||||
|
|
||||||
|
gameAchievementObserver[gameId] = new AbortController();
|
||||||
|
|
||||||
|
const achievements = checkedAchievements.all;
|
||||||
|
|
||||||
|
for (const file of achievementFiles) {
|
||||||
|
const signal = gameAchievementObserver[gameId]?.signal;
|
||||||
|
if (!signal) return;
|
||||||
|
console.log(`cracker: ${file.type}, steamId: ${steamId}`);
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const watcher = watch(file.filePath, {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
for await (const event of watcher) {
|
||||||
|
if (event.eventType === "change") {
|
||||||
|
console.log("file modified");
|
||||||
|
const localAchievementFile = await parseAchievementFile(
|
||||||
|
file.filePath
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!localAchievementFile) continue;
|
||||||
|
|
||||||
|
const checked = checkUnlockedAchievements(
|
||||||
|
file.type,
|
||||||
|
localAchievementFile,
|
||||||
|
achievements
|
||||||
|
);
|
||||||
|
|
||||||
|
if (checked.new) {
|
||||||
|
console.log(checked.new);
|
||||||
|
|
||||||
|
gameAchievementRepository.update(
|
||||||
|
{
|
||||||
|
game: { id: steamId },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
achievements: JSON.stringify(checked.all),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log(`cracker: ${file.type}, steamId ${steamId}`);
|
||||||
|
if (err?.name === "AbortError") return;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stopGameAchievementObserver = (gameId: number) => {
|
||||||
|
console.log(`Stopping: ${gameId}`);
|
||||||
|
gameAchievementObserver[gameId]?.abort();
|
||||||
|
delete gameAchievementObserver[gameId];
|
||||||
|
};
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { gameRepository, gameAchievementRepository } from "@main/repository";
|
||||||
|
import { steamGetAchivement } from "../steam/steam-get-achivement";
|
||||||
|
import { steamFindGameAchievementFiles } from "../steam/steam-find-game-achivement-files";
|
||||||
|
import { AchievementFile, CheckedAchievements } from "../types";
|
||||||
|
import { parseAchievementFile } from "../util/parseAchievementFile";
|
||||||
|
import { checkUnlockedAchievements } from "../util/check-unlocked-achievements";
|
||||||
|
|
||||||
|
export const getGameAchievementsToWatch = async (
|
||||||
|
gameId: number
|
||||||
|
): Promise<
|
||||||
|
| {
|
||||||
|
steamId: number;
|
||||||
|
checkedAchievements: CheckedAchievements;
|
||||||
|
achievementFiles: AchievementFile[];
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
> => {
|
||||||
|
const game = await gameRepository.findOne({ where: { id: gameId } });
|
||||||
|
|
||||||
|
if (!game || game.shop !== "steam") return;
|
||||||
|
|
||||||
|
const steamId = Number(game.objectID);
|
||||||
|
|
||||||
|
const achievements = await steamGetAchivement(game);
|
||||||
|
console.log(achievements);
|
||||||
|
if (!achievements || !achievements.length) return;
|
||||||
|
|
||||||
|
const achievementFiles = steamFindGameAchievementFiles(game.objectID)[
|
||||||
|
steamId
|
||||||
|
];
|
||||||
|
console.log(achievementFiles);
|
||||||
|
if (!achievementFiles || !achievementFiles.length) return;
|
||||||
|
|
||||||
|
const checkedAchievements: CheckedAchievements = {
|
||||||
|
all: achievements,
|
||||||
|
new: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const achievementFile of achievementFiles) {
|
||||||
|
const file = await parseAchievementFile(achievementFile.filePath);
|
||||||
|
|
||||||
|
checkedAchievements.new.push(
|
||||||
|
...checkUnlockedAchievements(achievementFile.type, file, achievements).new
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkedAchievements.new.length) {
|
||||||
|
await gameAchievementRepository.update(
|
||||||
|
{
|
||||||
|
game: { id: gameId },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
achievements: JSON.stringify(checkedAchievements.all),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { steamId, checkedAchievements, achievementFiles };
|
||||||
|
};
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { gameAchievementRepository, gameRepository } from "@main/repository";
|
||||||
|
import { steamFindGameAchievementFiles } from "../steam/steam-find-game-achivement-files";
|
||||||
|
import { steamGlobalAchievementPercentages } from "../steam/steam-global-achievement-percentages";
|
||||||
|
import { steamAchievementInfo } from "../steam/steam-achievement-info";
|
||||||
|
import { steamAchievementMerge } from "../steam/steam-achievement-merge";
|
||||||
|
import { parseAchievementFile } from "../util/parseAchievementFile";
|
||||||
|
import { checkUnlockedAchievements } from "../util/check-unlocked-achievements";
|
||||||
|
import { CheckedAchievements } from "../types";
|
||||||
|
|
||||||
|
export const saveAllLocalSteamAchivements = async () => {
|
||||||
|
const gameAchievementFiles = steamFindGameAchievementFiles();
|
||||||
|
|
||||||
|
for (const key of Object.keys(gameAchievementFiles)) {
|
||||||
|
const objectId = key;
|
||||||
|
|
||||||
|
const game = await gameRepository.findOne({
|
||||||
|
where: { objectID: objectId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!game) continue;
|
||||||
|
|
||||||
|
const hasOnDb = await gameAchievementRepository.existsBy({
|
||||||
|
game: game,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasOnDb) continue;
|
||||||
|
|
||||||
|
const achievementPercentage =
|
||||||
|
await steamGlobalAchievementPercentages(objectId);
|
||||||
|
|
||||||
|
if (!achievementPercentage) {
|
||||||
|
await gameAchievementRepository.save({
|
||||||
|
game,
|
||||||
|
achievements: "[]",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const achievementInfo = await steamAchievementInfo(objectId);
|
||||||
|
|
||||||
|
if (!achievementInfo) continue;
|
||||||
|
|
||||||
|
const achievements = steamAchievementMerge(
|
||||||
|
achievementPercentage,
|
||||||
|
achievementInfo
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!achievements) continue;
|
||||||
|
|
||||||
|
const checkedAchievements: CheckedAchievements = {
|
||||||
|
all: achievements,
|
||||||
|
new: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const achievementFile of gameAchievementFiles[key]) {
|
||||||
|
const localAchievementFile = await parseAchievementFile(
|
||||||
|
achievementFile.filePath
|
||||||
|
);
|
||||||
|
|
||||||
|
checkedAchievements.new.push(
|
||||||
|
...checkUnlockedAchievements(
|
||||||
|
achievementFile.type,
|
||||||
|
localAchievementFile,
|
||||||
|
achievements
|
||||||
|
).new
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await gameAchievementRepository.save({
|
||||||
|
game,
|
||||||
|
achievements: JSON.stringify(checkedAchievements.all),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
54
src/main/events/achievements/steam/steam-achievement-info.ts
Normal file
54
src/main/events/achievements/steam/steam-achievement-info.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import { logger } from "@main/services";
|
||||||
|
import { AchievementInfo } from "../types";
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
|
||||||
|
export const steamAchievementInfo = async (
|
||||||
|
objectId: string
|
||||||
|
): Promise<AchievementInfo[] | undefined> => {
|
||||||
|
const fetchUrl = `https://steamcommunity.com/stats/${objectId}/achievements`;
|
||||||
|
|
||||||
|
const achievementInfosHtmlText = await fetch(fetchUrl, {
|
||||||
|
method: "GET",
|
||||||
|
//headers: { "Accept-Language": "" },
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (res.status === 200) return res.text();
|
||||||
|
throw new Error();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.error(err, { method: "getSteamGameAchievements" });
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!achievementInfosHtmlText) return;
|
||||||
|
|
||||||
|
const achievementInfos: AchievementInfo[] = [];
|
||||||
|
|
||||||
|
const window = new JSDOM(achievementInfosHtmlText).window;
|
||||||
|
|
||||||
|
const itens = Array.from(
|
||||||
|
window.document.getElementsByClassName("achieveRow")
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const item of itens) {
|
||||||
|
const imageUrl = item
|
||||||
|
.getElementsByClassName("achieveImgHolder")?.[0]
|
||||||
|
.getElementsByTagName("img")?.[0]?.src;
|
||||||
|
|
||||||
|
const achievementName = item
|
||||||
|
.getElementsByClassName("achieveTxt")?.[0]
|
||||||
|
.getElementsByTagName("h3")?.[0].innerHTML;
|
||||||
|
|
||||||
|
const achievementDescription = item
|
||||||
|
.getElementsByClassName("achieveTxt")?.[0]
|
||||||
|
.getElementsByTagName("h5")?.[0].innerHTML;
|
||||||
|
|
||||||
|
achievementInfos.push({
|
||||||
|
imageUrl: imageUrl ?? "",
|
||||||
|
title: achievementName ?? "",
|
||||||
|
description: achievementDescription ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return achievementInfos;
|
||||||
|
};
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { Achievement, AchievementInfo, AchievementPercentage } from "../types";
|
||||||
|
|
||||||
|
export const steamAchievementMerge = (
|
||||||
|
achievementPercentage: AchievementPercentage[],
|
||||||
|
achievementInfo: AchievementInfo[]
|
||||||
|
): Achievement[] | undefined => {
|
||||||
|
if (achievementPercentage.length > achievementInfo.length) return;
|
||||||
|
|
||||||
|
const size = achievementPercentage.length;
|
||||||
|
|
||||||
|
const achievements: Achievement[] = new Array(size);
|
||||||
|
|
||||||
|
for (let i = 0; i < size; i++) {
|
||||||
|
achievements[i] = {
|
||||||
|
id: achievementPercentage[i].name,
|
||||||
|
percent: achievementPercentage[i].percent,
|
||||||
|
imageUrl: achievementInfo[i].imageUrl,
|
||||||
|
title: achievementInfo[i].title,
|
||||||
|
description: achievementInfo[i].description,
|
||||||
|
achieved: false,
|
||||||
|
curProgress: 0,
|
||||||
|
maxProgress: 0,
|
||||||
|
unlockTime: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return achievements;
|
||||||
|
};
|
|
@ -0,0 +1,86 @@
|
||||||
|
import path from "node:path";
|
||||||
|
import { existsSync, readdirSync } from "node:fs";
|
||||||
|
import { Cracker, GameAchievementFiles } from "../types";
|
||||||
|
import { app } from "electron";
|
||||||
|
|
||||||
|
const addGame = (
|
||||||
|
achievementFiles: GameAchievementFiles,
|
||||||
|
gamePath: string,
|
||||||
|
objectId: string,
|
||||||
|
fileLocation: string[],
|
||||||
|
type: Cracker
|
||||||
|
) => {
|
||||||
|
const filePath = path.join(gamePath, objectId, ...fileLocation);
|
||||||
|
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
const achivementFile = {
|
||||||
|
type,
|
||||||
|
filePath: filePath,
|
||||||
|
};
|
||||||
|
|
||||||
|
achievementFiles[objectId]
|
||||||
|
? achievementFiles[objectId].push(achivementFile)
|
||||||
|
: (achievementFiles[objectId] = [achivementFile]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const steamFindGameAchievementFiles = (
|
||||||
|
objectId?: string
|
||||||
|
): GameAchievementFiles => {
|
||||||
|
//TODO: change to a automatized method
|
||||||
|
const publicDir = path.join("C:", "Users", "Public", "Documents");
|
||||||
|
const appData = app.getPath("appData");
|
||||||
|
|
||||||
|
const gameAchievementFiles: GameAchievementFiles = {};
|
||||||
|
|
||||||
|
const crackers: Cracker[] = [
|
||||||
|
Cracker.codex,
|
||||||
|
Cracker.goldberg,
|
||||||
|
Cracker.rune,
|
||||||
|
Cracker.onlineFix,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const cracker of crackers) {
|
||||||
|
let achievementPath: string;
|
||||||
|
let fileLocation: string[];
|
||||||
|
|
||||||
|
if (cracker === Cracker.onlineFix) {
|
||||||
|
achievementPath = path.join(publicDir, Cracker.onlineFix);
|
||||||
|
fileLocation = ["Stats", "Achievements.ini"];
|
||||||
|
} else if (cracker === Cracker.goldberg) {
|
||||||
|
achievementPath = path.join(appData, "Goldberg SteamEmu Saves");
|
||||||
|
fileLocation = ["achievements.json"];
|
||||||
|
} else {
|
||||||
|
achievementPath = path.join(publicDir, "Steam", cracker);
|
||||||
|
fileLocation = ["achievements.ini"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(achievementPath)) continue;
|
||||||
|
|
||||||
|
const objectIds = readdirSync(achievementPath);
|
||||||
|
|
||||||
|
if (objectId) {
|
||||||
|
if (objectIds.includes(objectId)) {
|
||||||
|
addGame(
|
||||||
|
gameAchievementFiles,
|
||||||
|
achievementPath,
|
||||||
|
objectId,
|
||||||
|
fileLocation,
|
||||||
|
cracker
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const objectId of objectIds) {
|
||||||
|
addGame(
|
||||||
|
gameAchievementFiles,
|
||||||
|
achievementPath,
|
||||||
|
objectId,
|
||||||
|
fileLocation,
|
||||||
|
cracker
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return gameAchievementFiles;
|
||||||
|
};
|
48
src/main/events/achievements/steam/steam-get-achivement.ts
Normal file
48
src/main/events/achievements/steam/steam-get-achivement.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import { gameAchievementRepository } from "@main/repository";
|
||||||
|
import { steamGlobalAchievementPercentages } from "./steam-global-achievement-percentages";
|
||||||
|
import { steamAchievementInfo } from "./steam-achievement-info";
|
||||||
|
import { steamAchievementMerge } from "./steam-achievement-merge";
|
||||||
|
import { Achievement } from "../types";
|
||||||
|
import { Game } from "@main/entity";
|
||||||
|
|
||||||
|
export const steamGetAchivement = async (
|
||||||
|
game: Game
|
||||||
|
): Promise<Achievement[] | undefined> => {
|
||||||
|
const gameAchivement = await gameAchievementRepository.findOne({
|
||||||
|
where: { game: game },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!gameAchivement) {
|
||||||
|
const achievementPercentage = await steamGlobalAchievementPercentages(
|
||||||
|
game.objectID
|
||||||
|
);
|
||||||
|
console.log(achievementPercentage);
|
||||||
|
if (!achievementPercentage) {
|
||||||
|
await gameAchievementRepository.save({
|
||||||
|
game,
|
||||||
|
achievements: "[]",
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const achievementInfo = await steamAchievementInfo(game.objectID);
|
||||||
|
console.log(achievementInfo);
|
||||||
|
if (!achievementInfo) return;
|
||||||
|
|
||||||
|
const achievements = steamAchievementMerge(
|
||||||
|
achievementPercentage,
|
||||||
|
achievementInfo
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!achievements) return;
|
||||||
|
|
||||||
|
await gameAchievementRepository.save({
|
||||||
|
game,
|
||||||
|
achievements: JSON.stringify(achievements),
|
||||||
|
});
|
||||||
|
|
||||||
|
return achievements;
|
||||||
|
} else {
|
||||||
|
return JSON.parse(gameAchivement.achievements);
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { logger } from "@main/services";
|
||||||
|
import { AchievementPercentage } from "../types";
|
||||||
|
|
||||||
|
interface GlobalAchievementPercentages {
|
||||||
|
achievementpercentages: {
|
||||||
|
achievements: Array<AchievementPercentage>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const steamGlobalAchievementPercentages = async (
|
||||||
|
objectId: string
|
||||||
|
): Promise<AchievementPercentage[] | undefined> => {
|
||||||
|
const fetchUrl = `https://api.steampowered.com/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v0002/?gameid=${objectId}`;
|
||||||
|
|
||||||
|
const achievementPercentages: Array<AchievementPercentage> | undefined = (
|
||||||
|
await fetch(fetchUrl, {
|
||||||
|
method: "GET",
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (res.status === 200) return res.json();
|
||||||
|
return;
|
||||||
|
})
|
||||||
|
.then((data: GlobalAchievementPercentages) => data)
|
||||||
|
.catch((err) => {
|
||||||
|
logger.error(err, { method: "getSteamGameAchievements" });
|
||||||
|
return;
|
||||||
|
})
|
||||||
|
)?.achievementpercentages.achievements;
|
||||||
|
|
||||||
|
if (!achievementPercentages) return;
|
||||||
|
|
||||||
|
return achievementPercentages;
|
||||||
|
};
|
52
src/main/events/achievements/types/index.ts
Normal file
52
src/main/events/achievements/types/index.ts
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
export enum Cracker {
|
||||||
|
codex = "CODEX",
|
||||||
|
rune = "RUNE",
|
||||||
|
onlineFix = "OnlineFix",
|
||||||
|
goldberg = "Goldberg",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckedAchievements {
|
||||||
|
all: Achievement[];
|
||||||
|
new: Achievement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Achievement {
|
||||||
|
id: string;
|
||||||
|
percent: number;
|
||||||
|
imageUrl: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
achieved: boolean;
|
||||||
|
curProgress: number;
|
||||||
|
maxProgress: number;
|
||||||
|
unlockTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AchievementInfo {
|
||||||
|
imageUrl: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AchievementPercentage {
|
||||||
|
name: string;
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckedAchievement {
|
||||||
|
all: Achievement[];
|
||||||
|
new: Achievement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AchievementFile {
|
||||||
|
type: Cracker;
|
||||||
|
filePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameAchievementFiles = {
|
||||||
|
[id: string]: AchievementFile[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GameAchievementFile = {
|
||||||
|
[id: string]: AchievementFile[];
|
||||||
|
};
|
102
src/main/events/achievements/util/check-unlocked-achievements.ts
Normal file
102
src/main/events/achievements/util/check-unlocked-achievements.ts
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
import { Achievement, CheckedAchievements, Cracker } from "../types";
|
||||||
|
|
||||||
|
export const checkUnlockedAchievements = (
|
||||||
|
type: Cracker,
|
||||||
|
unlockedAchievements: any,
|
||||||
|
achievements: Achievement[]
|
||||||
|
): CheckedAchievements => {
|
||||||
|
if (type === Cracker.onlineFix)
|
||||||
|
return onlineFixMerge(unlockedAchievements, achievements);
|
||||||
|
if (type === Cracker.goldberg)
|
||||||
|
return goldbergUnlockedAchievements(unlockedAchievements, achievements);
|
||||||
|
return defaultMerge(unlockedAchievements, achievements);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onlineFixMerge = (
|
||||||
|
unlockedAchievements: any,
|
||||||
|
achievements: Achievement[]
|
||||||
|
): CheckedAchievements => {
|
||||||
|
const newUnlockedAchievements: Achievement[] = [];
|
||||||
|
|
||||||
|
for (const achievement of achievements) {
|
||||||
|
if (achievement.achieved) continue;
|
||||||
|
|
||||||
|
const unlockedAchievement = unlockedAchievements[achievement.id];
|
||||||
|
|
||||||
|
if (!unlockedAchievement) continue;
|
||||||
|
|
||||||
|
achievement.achieved = Boolean(
|
||||||
|
unlockedAchievement?.achieved ?? achievement.achieved
|
||||||
|
);
|
||||||
|
|
||||||
|
achievement.unlockTime =
|
||||||
|
unlockedAchievement?.timestamp ?? achievement.unlockTime;
|
||||||
|
|
||||||
|
if (achievement.achieved) {
|
||||||
|
newUnlockedAchievements.push(achievement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { all: achievements, new: newUnlockedAchievements };
|
||||||
|
};
|
||||||
|
|
||||||
|
const goldbergUnlockedAchievements = (
|
||||||
|
unlockedAchievements: any,
|
||||||
|
achievements: Achievement[]
|
||||||
|
): CheckedAchievements => {
|
||||||
|
const newUnlockedAchievements: Achievement[] = [];
|
||||||
|
|
||||||
|
for (const achievement of achievements) {
|
||||||
|
if (achievement.achieved) continue;
|
||||||
|
|
||||||
|
const unlockedAchievement = unlockedAchievements[achievement.id];
|
||||||
|
|
||||||
|
if (!unlockedAchievement) continue;
|
||||||
|
|
||||||
|
achievement.achieved = Boolean(
|
||||||
|
unlockedAchievement?.earned ?? achievement.achieved
|
||||||
|
);
|
||||||
|
|
||||||
|
achievement.unlockTime =
|
||||||
|
unlockedAchievement?.earned_time ?? achievement.unlockTime;
|
||||||
|
|
||||||
|
if (achievement.achieved) {
|
||||||
|
newUnlockedAchievements.push(achievement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { all: achievements, new: newUnlockedAchievements };
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultMerge = (
|
||||||
|
unlockedAchievements: any,
|
||||||
|
achievements: Achievement[]
|
||||||
|
): CheckedAchievements => {
|
||||||
|
const newUnlockedAchievements: Achievement[] = [];
|
||||||
|
console.log("checkUnlockedAchievements");
|
||||||
|
for (const achievement of achievements) {
|
||||||
|
if (achievement.achieved) continue;
|
||||||
|
|
||||||
|
const unlockedAchievement = unlockedAchievements[achievement.id];
|
||||||
|
|
||||||
|
if (!unlockedAchievement) continue;
|
||||||
|
|
||||||
|
achievement.achieved = Boolean(
|
||||||
|
unlockedAchievement?.Achieved ?? achievement.achieved
|
||||||
|
);
|
||||||
|
|
||||||
|
achievement.curProgress =
|
||||||
|
unlockedAchievement?.CurProgress ?? achievement.curProgress;
|
||||||
|
|
||||||
|
achievement.maxProgress =
|
||||||
|
unlockedAchievement?.MaxProgress ?? achievement.maxProgress;
|
||||||
|
|
||||||
|
achievement.unlockTime =
|
||||||
|
unlockedAchievement?.UnlockTime ?? achievement.unlockTime;
|
||||||
|
|
||||||
|
if (achievement.achieved) {
|
||||||
|
newUnlockedAchievements.push(achievement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("newUnlocked: ", newUnlockedAchievements);
|
||||||
|
return { all: achievements, new: newUnlockedAchievements };
|
||||||
|
};
|
57
src/main/events/achievements/util/parseAchievementFile.ts
Normal file
57
src/main/events/achievements/util/parseAchievementFile.ts
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import { existsSync, createReadStream, readFileSync } from "node:fs";
|
||||||
|
import readline from "node:readline";
|
||||||
|
|
||||||
|
export const parseAchievementFile = async (
|
||||||
|
filePath: string
|
||||||
|
): Promise<any | null> => {
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
if (filePath.endsWith(".ini")) {
|
||||||
|
return iniParse(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filePath.endsWith(".json")) {
|
||||||
|
return jsonParse(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const iniParse = async (filePath: string) => {
|
||||||
|
try {
|
||||||
|
const file = createReadStream(filePath);
|
||||||
|
|
||||||
|
const lines = readline.createInterface({
|
||||||
|
input: file,
|
||||||
|
crlfDelay: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
let objectName = "";
|
||||||
|
const object: any = {};
|
||||||
|
|
||||||
|
for await (const line of lines) {
|
||||||
|
if (line.startsWith("###") || !line.length) continue;
|
||||||
|
|
||||||
|
if (line.startsWith("[") && line.endsWith("]")) {
|
||||||
|
objectName = line.slice(1, -1);
|
||||||
|
object[objectName] = {};
|
||||||
|
} else {
|
||||||
|
const [name, value] = line.split("=");
|
||||||
|
|
||||||
|
const number = Number(value);
|
||||||
|
|
||||||
|
object[objectName][name] = isNaN(number) ? value : number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return object;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonParse = (filePath: string) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(filePath, "utf-8"));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
|
@ -6,6 +6,7 @@ import { UpdateUserLanguage } from "./migrations/20240913213944_update_user_lang
|
||||||
import { EnsureRepackUris } from "./migrations/20240915035339_ensure_repack_uris";
|
import { EnsureRepackUris } from "./migrations/20240915035339_ensure_repack_uris";
|
||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
import { FixMissingColumns } from "./migrations/20240918001920_FixMissingColumns";
|
import { FixMissingColumns } from "./migrations/20240918001920_FixMissingColumns";
|
||||||
|
import { CreateGameAchievement } from "./migrations/20240919030940_create_game_achievement";
|
||||||
|
|
||||||
export type HydraMigration = Knex.Migration & { name: string };
|
export type HydraMigration = Knex.Migration & { name: string };
|
||||||
|
|
||||||
|
@ -17,6 +18,7 @@ class MigrationSource implements Knex.MigrationSource<HydraMigration> {
|
||||||
UpdateUserLanguage,
|
UpdateUserLanguage,
|
||||||
EnsureRepackUris,
|
EnsureRepackUris,
|
||||||
FixMissingColumns,
|
FixMissingColumns,
|
||||||
|
CreateGameAchievement,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
getMigrationName(migration: HydraMigration): string {
|
getMigrationName(migration: HydraMigration): string {
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { HydraMigration } from "@main/knex-client";
|
||||||
|
import type { Knex } from "knex";
|
||||||
|
|
||||||
|
export const CreateGameAchievement: HydraMigration = {
|
||||||
|
name: "CreateGameAchievement",
|
||||||
|
up: async (knex: Knex) => {
|
||||||
|
await knex.schema.createTable("game_achievement", (table) => {
|
||||||
|
table.increments("id").primary();
|
||||||
|
table.integer("gameId").notNullable();
|
||||||
|
table.text("achievements");
|
||||||
|
table.foreign("gameId").references("game.id").onDelete("CASCADE");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (knex: Knex) => {
|
||||||
|
await knex.schema.dropTable("game_achievement");
|
||||||
|
},
|
||||||
|
};
|
|
@ -7,6 +7,7 @@ import {
|
||||||
Repack,
|
Repack,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
UserAuth,
|
UserAuth,
|
||||||
|
GameAchievement,
|
||||||
} from "@main/entity";
|
} from "@main/entity";
|
||||||
|
|
||||||
export const gameRepository = dataSource.getRepository(Game);
|
export const gameRepository = dataSource.getRepository(Game);
|
||||||
|
@ -24,3 +25,6 @@ export const downloadSourceRepository =
|
||||||
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
|
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
|
||||||
|
|
||||||
export const userAuthRepository = dataSource.getRepository(UserAuth);
|
export const userAuthRepository = dataSource.getRepository(UserAuth);
|
||||||
|
|
||||||
|
export const gameAchievementRepository =
|
||||||
|
dataSource.getRepository(GameAchievement);
|
||||||
|
|
|
@ -5,6 +5,10 @@ import { createGame, updateGamePlaytime } from "./library-sync";
|
||||||
import { GameRunning } from "@types";
|
import { GameRunning } from "@types";
|
||||||
import { PythonInstance } from "./download";
|
import { PythonInstance } from "./download";
|
||||||
import { Game } from "@main/entity";
|
import { Game } from "@main/entity";
|
||||||
|
import {
|
||||||
|
startGameAchievementObserver,
|
||||||
|
stopGameAchievementObserver,
|
||||||
|
} from "@main/events/achievements/game-achievements-observer";
|
||||||
|
|
||||||
export const gamesPlaytime = new Map<
|
export const gamesPlaytime = new Map<
|
||||||
number,
|
number,
|
||||||
|
@ -74,6 +78,8 @@ function onOpenGame(game: Game) {
|
||||||
} else {
|
} else {
|
||||||
createGame({ ...game, lastTimePlayed: new Date() }).catch(() => {});
|
createGame({ ...game, lastTimePlayed: new Date() }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startGameAchievementObserver(game.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTickGame(game: Game) {
|
function onTickGame(game: Game) {
|
||||||
|
@ -125,4 +131,6 @@ const onCloseGame = (game: Game) => {
|
||||||
} else {
|
} else {
|
||||||
createGame(game).catch(() => {});
|
createGame(game).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stopGameAchievementObserver(game.id);
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue