@cruncheevos/cli
Version:
Maintain achievement sets for RetroAchievements.org using JavaScript, an alternative to RATools
207 lines (206 loc) • 7.95 kB
JavaScript
import { Achievement, Leaderboard } from '@cruncheevos/core';
import { wrappedError } from '@cruncheevos/core/util';
import * as util from 'util';
import { getRemoteData, getSetFromRemoteData } from './command-fetch.js';
import { confirm, getFs, log } from './mockable.js';
import { filtersMatch } from './util.js';
const fs = getFs();
function conditionIsSimple(condition) {
const { lvalue, rvalue } = condition;
return ((lvalue.type === 'Float' || lvalue.type === 'Value') &&
(rvalue.type === 'Float' || rvalue.type === 'Value'));
}
// TODO: would be nice to skip calling toArrayPretty
// solely for extracting the formatted number
function conditionsToJsCodeLines(conditions) {
return conditions.map((c, i, self) => {
const a = c.toArrayPretty();
const lValueFormatted = a[3].startsWith('0x') ? a[3] : Number(a[3]);
let line = `['${a[0]}', '${a[1]}', '${a[2]}', ${lValueFormatted}`;
const hasCmp = a[4];
if (hasCmp) {
const rValueFormatted = a[7].startsWith('0x') ? a[7] : Number(a[7]);
line += `, '${a[4]}', '${a[5]}', '${a[6]}', ${rValueFormatted}`;
if (c.hits > 0) {
line += `, ${c.hits}`;
}
}
line += ']';
if (i < self.length - 1) {
line += ',';
}
return line;
});
}
function groupSetToJsCode(groupSet, spaces) {
// don't touch conditions like '1=1'
if (groupSet.length === 1 && groupSet[0].length === 1 && conditionIsSimple(groupSet[0][0])) {
return `'${groupSet[0].toString()}'`;
}
const groups = groupSet.map(x => {
const lines = conditionsToJsCodeLines(x);
// $([...])
if (lines.length <= 1) {
return [`$(${lines[0]})`];
}
// $(
// [...],
// [...],
// )
return [
`$(`,
...lines.map((line, i, self) => ' ' + line + (i === self.length - 1 ? ',' : '')),
`)`,
];
});
const w = ' '.repeat(spaces);
if (groups.length === 1) {
return groups[0].map((x, i) => (i === 0 ? x : w + x)).join('\n');
}
// {
// core: $(
// [...],
// [...],
// ),
// alt1: $(
// [...],
// [...],
// ),
// }
return [
'{',
groups
.map((x, i) => {
const groupName = i === 0 ? 'core: ' : `alt${i}: `;
const total = x
.map((y, i) => {
if (i === 0) {
y = groupName + y;
}
return w + ' ' + y;
})
.join('\n');
return total;
})
.join(',\n')
.concat(','),
w + '}',
].join('\n');
}
// Prefer ` if string has quotes, otherwise wrap into single quote
function quoted(str) {
const stringHasQuotes = Boolean(str.match(/['"]/));
return stringHasQuotes ? `\`${str.replace(/`/g, '\\`')}\`` : `'${str}'`;
}
function makeFixmeComments(title, description) {
const titleFixme = title ? '' : ' // FIXME';
const descriptionFixme = description ? '' : ' // FIXME';
const lackingPieces = [titleFixme && 'title', descriptionFixme && 'description']
.filter(Boolean)
.join(' and ');
return { titleFixme, descriptionFixme, lackingPieces };
}
// TODO: add setId support
function remoteDataToJSCode(remoteData, { filter, includeUnofficial }) {
const { GameId, Achievements, Leaderboards } = getSetFromRemoteData(remoteData);
let src = '';
src += `import { AchievementSet, define as $ } from '@cruncheevos/core'\n`;
src += `const set = new AchievementSet({ gameId: ${GameId}, title: ${quoted(remoteData.Title)} })\n\n`;
for (const ach of Achievements) {
if (ach.Flags !== 3 && !(ach.Flags === 5 && includeUnofficial)) {
continue;
}
if (filter.length > 0 &&
filtersMatch({ id: ach.ID, title: ach.Title, description: ach.Description }, filter) === false) {
continue;
}
try {
const { conditions } = new Achievement({
id: 1,
title: 'title',
description: '',
points: 0,
conditions: ach.MemAddr,
});
const { titleFixme, descriptionFixme, lackingPieces } = makeFixmeComments(ach.Title, ach.Description);
const achType = ach.Type || '';
src += [
`set.addAchievement({`,
` id: ${ach.ID},`,
` badge: ${quoted(ach.BadgeName)},`,
` title: ${quoted(ach.Title)},${titleFixme}`,
` description: ${quoted(ach.Description)},${descriptionFixme}`,
` points: ${ach.Points},`,
achType ? ` type: ${quoted(achType)},` : ``,
` conditions: ${groupSetToJsCode(conditions, 2)},`,
`})\n\n`,
]
.filter(Boolean)
.join('\n');
if (lackingPieces) {
log(util.styleText('yellowBright', `Achievement with ID ${ach.ID} lacks ${lackingPieces}, marked with FIXME`));
}
}
catch (err) {
throw wrappedError(err, `Achievement ID ${ach.ID} (${ach.Title}): ${err.message}`);
}
}
for (const lb of Leaderboards) {
if (lb.Hidden && !includeUnofficial) {
continue;
}
if (filter.length > 0 &&
filtersMatch({ id: lb.ID, title: lb.Title, description: lb.Description }, filter) === false) {
continue;
}
const leaderboardType = lb.Format === 'TIME' ? 'FRAMES' : lb.Format;
const { titleFixme, descriptionFixme, lackingPieces } = makeFixmeComments(lb.Title, lb.Description);
try {
const { conditions } = new Leaderboard({
id: 1,
title: 'title',
description: '',
type: 'VALUE',
lowerIsBetter: true,
conditions: lb.Mem,
});
src += [
`set.addLeaderboard({`,
` id: ${lb.ID},`,
` title: ${quoted(lb.Title)},${titleFixme}`,
` description: ${quoted(lb.Description)},${descriptionFixme}`,
` lowerIsBetter: ${Boolean(lb.LowerIsBetter)},`,
` type: ${quoted(leaderboardType)},`,
` conditions: {`,
` start: ${groupSetToJsCode(conditions.start, 4)},`,
` cancel: ${groupSetToJsCode(conditions.cancel, 4)},`,
` submit: ${groupSetToJsCode(conditions.submit, 4)},`,
` value: ${groupSetToJsCode(conditions.value, 4)},`,
` },`,
`})\n\n`,
].join('\n');
}
catch (err) {
throw wrappedError(err, `Leaderboard ID ${lb.ID} (${lb.Title}): ${err.message}`);
}
if (lackingPieces) {
log(util.styleText('yellowBright', `Leaderboard with ID ${lb.ID} lacks ${lackingPieces}, marked with FIXME`));
}
}
return src + `export default set\n`;
}
// TODO: add setId support
export default async function generate(gameId, outputFilePath, opts) {
const fileAlreadyExists = fs.existsSync(outputFilePath);
if (fileAlreadyExists) {
const agreedToOverwrite = await confirm(`file ${outputFilePath} already exists, overwrite?`);
if (agreedToOverwrite === false) {
return;
}
}
const { includeUnofficial, filter = [], timeout = 1000 } = opts;
const remoteData = await getRemoteData({ gameId, timeout });
const code = remoteDataToJSCode(remoteData, { filter, includeUnofficial });
fs.writeFileSync(outputFilePath, code);
log(`generated code for achievement set for gameId ${gameId}: ${outputFilePath}`);
}