feature: wip-game-achievements

refactor: rename files
This commit is contained in:
JackEnx 2024-05-08 15:38:52 -03:00 committed by Zamitto
parent fabeedaa8a
commit 8fb62af0cf
18 changed files with 739 additions and 0 deletions

View file

@ -7,6 +7,7 @@ import {
Repack,
UserPreferences,
UserAuth,
GameAchievement,
} from "@main/entity";
import { databasePath } from "./constants";
@ -21,6 +22,7 @@ export const dataSource = new DataSource({
DownloadSource,
DownloadQueue,
UserAuth,
GameAchievement,
],
synchronize: false,
database: databasePath,

View 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;
}

View file

@ -2,6 +2,8 @@ export * from "./game.entity";
export * from "./repack.entity";
export * from "./user-preferences.entity";
export * from "./game-shop-cache.entity";
export * from "./game.entity";
export * from "./game-achievements.entity";
export * from "./download-source.entity";
export * from "./download-queue.entity";
export * from "./user-auth";

View 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];
};

View file

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

View file

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

View 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;
};

View file

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

View file

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

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

View file

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

View 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[];
};

View 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 };
};

View 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;
}
};

View file

@ -6,6 +6,7 @@ import { UpdateUserLanguage } from "./migrations/20240913213944_update_user_lang
import { EnsureRepackUris } from "./migrations/20240915035339_ensure_repack_uris";
import { app } from "electron";
import { FixMissingColumns } from "./migrations/20240918001920_FixMissingColumns";
import { CreateGameAchievement } from "./migrations/20240919030940_create_game_achievement";
export type HydraMigration = Knex.Migration & { name: string };
@ -17,6 +18,7 @@ class MigrationSource implements Knex.MigrationSource<HydraMigration> {
UpdateUserLanguage,
EnsureRepackUris,
FixMissingColumns,
CreateGameAchievement,
]);
}
getMigrationName(migration: HydraMigration): string {

View file

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

View file

@ -7,6 +7,7 @@ import {
Repack,
UserPreferences,
UserAuth,
GameAchievement,
} from "@main/entity";
export const gameRepository = dataSource.getRepository(Game);
@ -24,3 +25,6 @@ export const downloadSourceRepository =
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
export const userAuthRepository = dataSource.getRepository(UserAuth);
export const gameAchievementRepository =
dataSource.getRepository(GameAchievement);

View file

@ -5,6 +5,10 @@ import { createGame, updateGamePlaytime } from "./library-sync";
import { GameRunning } from "@types";
import { PythonInstance } from "./download";
import { Game } from "@main/entity";
import {
startGameAchievementObserver,
stopGameAchievementObserver,
} from "@main/events/achievements/game-achievements-observer";
export const gamesPlaytime = new Map<
number,
@ -74,6 +78,8 @@ function onOpenGame(game: Game) {
} else {
createGame({ ...game, lastTimePlayed: new Date() }).catch(() => {});
}
startGameAchievementObserver(game.id);
}
function onTickGame(game: Game) {
@ -125,4 +131,6 @@ const onCloseGame = (game: Game) => {
} else {
createGame(game).catch(() => {});
}
stopGameAchievementObserver(game.id);
};