@cruncheevos/cli
Version:
Maintain achievement sets for RetroAchievements.org using JavaScript, an alternative to RATools
192 lines (191 loc) • 7.46 kB
JavaScript
import { Achievement, Leaderboard } from '@cruncheevos/core';
import { wrappedError } from '@cruncheevos/core/util';
import prettier from 'prettier';
import chalk from 'chalk';
import { getRemoteData } from './command-fetch.js';
import { confirm, getFs, log } from './mockable.js';
import { filtersMatch } from './util.js';
const fs = getFs();
const quotedHexStringRegex = /"(0x[\dabcdef]+)"/g;
function conditionIsSimple(condition) {
const { lvalue, rvalue } = condition;
return ((lvalue.type === 'Float' || lvalue.type === 'Value') &&
(rvalue.type === 'Float' || rvalue.type === 'Value'));
}
function conditionsToJsCode(conditions) {
const formattedConditions = conditions.map(condition => {
const array = condition.toArrayPretty();
const result = [
array[0],
array[1],
array[2],
array[3].startsWith('0x') ? array[3] : Number(array[3]),
];
const hasCmp = array[4];
if (hasCmp) {
result.push(array[4], array[5], array[6], array[7].startsWith('0x') ? array[7] : Number(array[7]));
if (condition.hits > 0) {
result.push(condition.hits);
}
}
return result;
});
// Replace outer [ ] with function call of $
return ('$(' +
JSON.stringify(formattedConditions).slice(1, -1).replace(quotedHexStringRegex, '$1') +
')');
}
function achievementConditionStringToJsCode(str) {
const ach = new Achievement({
id: 1,
title: 'dummy',
points: 0,
conditions: str,
});
return templatedConditionGroupSet(ach.conditions);
}
function templatedConditionGroupSet(conditions) {
// don't touch conditions like '1=1'
if (conditions.length === 1 &&
conditions[0].length === 1 &&
conditionIsSimple(conditions[0][0])) {
return '"' + conditions[0].toString() + '"';
}
// conditions: [ ... ]
if (conditions.length === 1) {
return conditionsToJsCode(conditions[0]);
}
/*
conditions: {
core: [ ... ],
alt1: [ ... ]
}
*/
const prefix = '%';
let res = '';
const placeholders = {};
const template = conditions.reduce((prev, group, i) => {
const groupName = i === 0 ? 'core' : `alt${i}`;
const placeholder = prefix + groupName;
prev[groupName] = placeholder;
placeholders[placeholder] = conditionsToJsCode(group);
return prev;
}, {});
res = JSON.stringify(template);
Object.keys(placeholders).forEach(group => {
res = res.replace('"' + group + '"', placeholders[group]);
});
return res;
}
function leaderboardConditionStringToJsCode(str) {
const lb = new Leaderboard({
id: 1,
title: 'dummy',
type: 'VALUE',
lowerIsBetter: true,
conditions: str,
});
return JSON.stringify({
start: 'lb_start',
cancel: 'lb_cancel',
submit: 'lb_submit',
value: 'lb_value',
})
.replace('"lb_start"', templatedConditionGroupSet(lb.conditions.start))
.replace('"lb_cancel"', templatedConditionGroupSet(lb.conditions.cancel))
.replace('"lb_submit"', templatedConditionGroupSet(lb.conditions.submit))
.replace('"lb_value"', templatedConditionGroupSet(lb.conditions.value));
}
// 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 };
}
function remoteDataToJSCode(remoteData, { filter, includeUnofficial }) {
let src = '';
src += `import { AchievementSet, define as $ } from '@cruncheevos/core'\n`;
src += `const set = new AchievementSet({ gameId: ${remoteData.ID}, title: ${quoted(remoteData.Title)} })\n\n`;
for (const ach of remoteData.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 { titleFixme, descriptionFixme, lackingPieces } = makeFixmeComments(ach.Title, ach.Description);
let achType = ach.Type || '';
achType = achType ? `\n type: ${quoted(achType)},` : '';
src += `set.addAchievement({
title: ${quoted(ach.Title)},${titleFixme}
description: ${quoted(ach.Description)},${descriptionFixme}
points: ${ach.Points},${achType}
conditions: ${achievementConditionStringToJsCode(ach.MemAddr)},
badge: ${quoted(ach.BadgeName)},
id: ${ach.ID},
})\n\n`;
if (lackingPieces) {
log(chalk.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 remoteData.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 {
src += `set.addLeaderboard({
title: ${quoted(lb.Title)},${titleFixme}
description: ${quoted(lb.Description)},${descriptionFixme}
lowerIsBetter: ${Boolean(lb.LowerIsBetter)},
type: ${quoted(leaderboardType)},
conditions: ${leaderboardConditionStringToJsCode(lb.Mem)},
id: ${lb.ID},
})\n\n`;
}
catch (err) {
throw wrappedError(err, `Leaderboard ID ${lb.ID} (${lb.Title}): ${err.message}`);
}
if (lackingPieces) {
log(chalk.yellowBright(`Leaderboard with ID ${lb.ID} lacks ${lackingPieces}, marked with FIXME`));
}
}
src += `export default set`;
return prettier.format(src, {
semi: false,
singleQuote: true,
parser: 'typescript',
});
}
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 { refetch, includeUnofficial, filter = [], timeout = 1000 } = opts;
const remoteData = await getRemoteData({ gameId, refetch, timeout });
const code = await remoteDataToJSCode(remoteData, { filter, includeUnofficial });
fs.writeFileSync(outputFilePath, code);
log(`generated code for achievement set for gameId ${gameId}: ${outputFilePath}`);
}