@cruncheevos/cli
Version:
Maintain achievement sets for RetroAchievements.org using JavaScript, an alternative to RATools
164 lines (163 loc) • 5.48 kB
JavaScript
import { AchievementSet } from '@cruncheevos/core';
import { wrappedError } from '@cruncheevos/core/util';
import { getFs, log, resolveRACache } from './mockable.js';
const fs = getFs();
let cachedCredentials;
function getCredentials() {
if (cachedCredentials) {
return cachedCredentials;
}
const configFile = fs
.readdirSync(resolveRACache('./'))
.filter(x => x.match(/^raprefs.*\.cfg$/i))[0];
if (!configFile) {
throw new Error(`expected RAPrefs.cfg file, but found none`);
}
const configJSON = fs.readFileSync(resolveRACache(`./${configFile}`)).toString();
try {
var config = JSON.parse(configJSON);
}
catch (err) {
throw wrappedError(err, `${configFile}: ${err.message}`);
}
if (!config.Username) {
throw new Error(`${configFile}: expected Username property as string, but got ${config.Username}`);
}
if (!config.Token) {
throw new Error(`${configFile}: expected Token property as string, but got ${config.Token}`);
}
cachedCredentials = {
username: config.Username,
token: config.Token,
};
return cachedCredentials;
}
async function fetchRemoteData(opts) {
const { gameId, timeout } = opts;
const { username, token } = getCredentials();
const abortController = new AbortController();
const timeoutHandle = setTimeout(() => abortController.abort(), timeout);
const payload = await fetch(`https://retroachievements.org/dorequest.php?r=achievementsets&t=${token}&u=${username}&g=${gameId}`, {
headers: {
'User-Agent': 'cruncheevos-cli',
},
signal: abortController.signal,
})
.then(x => {
if (x.ok) {
return x.json();
}
else {
throw new Error(`failed to fetch remote data: HTTP ${x.status}`);
}
})
.catch(err => {
if (err instanceof DOMException && err.name === 'AbortError') {
throw wrappedError(err, `failed to fetch remote data: timed out`);
}
else {
throw err;
}
})
.finally(() => clearTimeout(timeoutHandle));
if (payload.Success !== true) {
throw new Error(`failed to fetch remote data: expected payload.Success to be true, but got ${payload.Success}`);
}
return payload;
}
export default async function fetchAssets(opts) {
const { gameId } = opts;
log(`fetching remote data for gameId ${gameId}`);
const gameData = await fetchRemoteData(opts);
const filePath = resolveRACache(`./RACache/Data/${gameId}.json`);
fs.writeFileSync(filePath, JSON.stringify(gameData));
log(`dumped remote data for gameId ${gameId}: ${filePath}`);
return gameData;
}
export async function getRemoteData({ gameId, timeout }) {
const filePath = resolveRACache(`./RACache/Data/${gameId}.json`);
if (fs.existsSync(filePath) === false) {
return fetchAssets({
gameId,
timeout,
});
}
const parsed = JSON.parse(fs.readFileSync(filePath).toString());
if ('Sets' in parsed === false) {
return {
...parsed,
Success: true,
Sets: [
{
Title: parsed.Title,
GameId: parsed.ID,
AchievementSetId: parsed.ID,
Type: 'core',
Achievements: parsed.Achievements,
Leaderboards: parsed.Leaderboards,
},
],
};
}
return parsed;
}
export function getSetFromRemoteData(remoteData, setId) {
if (setId) {
return remoteData.Sets.find(set => set.AchievementSetId === setId);
}
return remoteData.Sets.find(set => set.Type === 'core');
}
// TODO: add support for setId
export async function getSetFromRemote(opts) {
const { gameId, setId } = opts;
const remoteData = await getRemoteData(opts);
const { Achievements, Leaderboards } = getSetFromRemoteData(remoteData, setId);
const set = new AchievementSet({
gameId,
id: setId,
title: remoteData.Title,
});
Achievements.forEach((ach, i) => {
if (ach.Flags === 5 && opts.excludeUnofficial) {
return;
}
if (ach.Flags !== 3 && ach.Flags !== 5) {
return;
}
try {
set.addAchievement({
id: ach.ID,
title: ach.Title,
description: ach.Description,
points: ach.Points,
type: ach.Type || '',
author: ach.Author,
badge: ach.BadgeName,
conditions: ach.MemAddr,
});
}
catch (err) {
throw wrappedError(err, `Achievements[${i}]: ${err.message}`);
}
});
Leaderboards.forEach((lb, i) => {
if (lb.Hidden && opts.excludeUnofficial) {
return;
}
const leaderboardType = lb.Format === 'TIME' ? 'FRAMES' : lb.Format;
try {
set.addLeaderboard({
id: lb.ID,
title: lb.Title,
description: lb.Description,
lowerIsBetter: Boolean(lb.LowerIsBetter),
conditions: lb.Mem,
type: leaderboardType,
});
}
catch (err) {
throw wrappedError(err, `Leaderboards[${i}]: ${err.message}`);
}
});
return set;
}