@cruncheevos/cli
Version:
Maintain achievement sets for RetroAchievements.org using JavaScript, an alternative to RATools
604 lines (603 loc) • 24.7 kB
JavaScript
import { Achievement, AchievementSet, Leaderboard } from '@cruncheevos/core';
import chalk from 'chalk';
const fs = getFs();
import { getFs, log, resolveRACache } from './mockable.js';
import { wrappedError } from '@cruncheevos/core/util';
function conditionGroupSetsAreSame(groupSetOriginal, groupSetModified) {
if (groupSetOriginal.length !== groupSetModified.length) {
return false;
}
for (let groupSetIndex = 0; groupSetIndex < groupSetOriginal.length; groupSetIndex++) {
const groupOriginal = groupSetOriginal[groupSetIndex];
const groupModified = groupSetModified[groupSetIndex];
if (groupOriginal.length !== groupModified.length) {
return false;
}
for (let groupIndex = 0; groupIndex < groupOriginal.length; groupIndex++) {
if (groupOriginal[groupIndex].toString() !== groupModified[groupIndex].toString()) {
return false;
}
}
}
return true;
}
export async function extractAchievementSetFromModule(module, absoluteModulePath) {
if (typeof module.default === 'function') {
const setOrThenable = module.default();
if (setOrThenable.then) {
const set = await setOrThenable;
if (set instanceof AchievementSet) {
return set;
}
else {
throw new Error(`expected module "${absoluteModulePath}" to resolve into AchievementSet, but got ${set}`);
}
}
else {
if (setOrThenable instanceof AchievementSet) {
return setOrThenable;
}
else {
throw new Error(`expected module "${absoluteModulePath}" to return AchievementSet, but got ${setOrThenable}`);
}
}
}
if (module.default instanceof AchievementSet) {
return module.default;
}
else {
throw new Error(`expected module "${absoluteModulePath}" to default export AchievementSet, but got ${module.default}`);
}
}
export function getLocalData(opts) {
const filePath = resolveRACache(`./RACache/Data/${opts.gameId}-User.txt`);
if (fs.existsSync(filePath) === false) {
return null;
}
const content = fs.readFileSync(filePath).toString();
const [version, title, ...lines] = content.split(/\r?\n/);
if (version === undefined) {
throw new Error('expected a version number in local file on line 1 but got none');
}
if (title === undefined) {
throw new Error('expected a title in local file on line 2 but got none');
}
return {
eol: content[content.indexOf('\n') - 1] === '\r' ? '\r\n' : '\n',
version,
title,
entries: lines.map((line, idx) => {
idx = idx + 3;
if (!line.trim()) {
return { type: 'empty', idx, line };
}
else if (line.startsWith('N0:')) {
return { type: 'codenote', idx, line };
}
else {
try {
if (line.startsWith('L')) {
const asset = new Leaderboard(line);
return { type: 'leaderboard', idx, line, asset };
}
else {
const asset = new Achievement(line);
return { type: 'achievement', idx, line, asset };
}
}
catch (err) {
if (opts.throwOnFirstError) {
throw wrappedError(err, `line ${idx}: ${err.message}`);
}
return { type: 'error', idx, line, err };
}
}
}),
};
}
function badgeIsSet(badge) {
return Number(badge) > 0 || badge.startsWith('local\\\\');
}
function badgeIsSetById(badge) {
return Number(badge) > 0;
}
function remoteBadgeIsNotSet(badge) {
const num = Number(badge);
return num === 0 || Number.isNaN(num);
}
function badgeIsSetAgainstRemote(target, current) {
if (target instanceof Achievement === false || current instanceof Achievement === false) {
return false;
}
if (remoteBadgeIsNotSet(target.badge) && badgeIsSet(current.badge)) {
return true;
}
return badgeIsSetById(current.badge) && current.badge !== target.badge;
}
function badgeIsSetAgainstLocal(oldAsset, newAsset) {
if (oldAsset instanceof Achievement === false || newAsset instanceof Achievement === false) {
return false;
}
return oldAsset.badge !== newAsset.badge;
}
function compareAssets(a, b) {
return {
get haveSameTitle() {
return a.title === b.title;
},
get haveSameDescription() {
return a.description === b.description;
},
get haveSamePoints() {
return a.points === b.points;
},
get haveSameLowerIsBetter() {
return a.lowerIsBetter === b.lowerIsBetter;
},
get haveSameType() {
return a.type === b.type;
},
get haveSameCode() {
if (a instanceof Achievement && b instanceof Achievement) {
return conditionGroupSetsAreSame(a.conditions, b.conditions);
}
else if (a instanceof Leaderboard && b instanceof Leaderboard) {
return (conditionGroupSetsAreSame(a.conditions.start, b.conditions.start) &&
conditionGroupSetsAreSame(a.conditions.cancel, b.conditions.cancel) &&
conditionGroupSetsAreSame(a.conditions.submit, b.conditions.submit) &&
conditionGroupSetsAreSame(a.conditions.value, b.conditions.value));
}
else {
throw new Error('Both original and modified arguments must be same type of Asset');
}
},
get similarity() {
if (a instanceof Achievement && b instanceof Achievement) {
return ((Number(this.haveSameTitle) +
Number(this.haveSameDescription) +
Number(this.haveSamePoints) +
Number(this.haveSameType) +
Number(this.haveSameCode)) /
5);
}
else if (a instanceof Leaderboard && b instanceof Leaderboard) {
return ((Number(this.haveSameTitle) +
Number(this.haveSameDescription) +
Number(this.haveSameType) +
Number(this.haveSameLowerIsBetter) +
Number(this.haveSameCode)) /
5);
}
else {
throw new Error('Both original and modified arguments must be same type of Asset');
}
},
get areSame() {
if (a instanceof Achievement && b instanceof Achievement) {
return (this.haveSameTitle &&
this.haveSameDescription &&
this.haveSamePoints &&
this.haveSameType &&
this.haveSameCode);
}
else if (a instanceof Leaderboard && b instanceof Leaderboard) {
return (this.haveSameTitle &&
this.haveSameDescription &&
this.haveSameType &&
this.haveSameLowerIsBetter &&
this.haveSameCode);
}
else {
throw new Error('Both original and modified arguments must be same type of Asset');
}
},
get areDifferent() {
if (a instanceof Achievement && b instanceof Achievement) {
return (this.haveSameTitle === false ||
this.haveSameDescription === false ||
this.haveSamePoints === false ||
this.haveSameType === false ||
this.haveSameCode === false);
}
else if (a instanceof Leaderboard && b instanceof Leaderboard) {
return (this.haveSameTitle === false ||
this.haveSameDescription === false ||
this.haveSameType === false ||
this.haveSameLowerIsBetter === false ||
this.haveSameCode === false);
}
else {
throw new Error('Both original and modified arguments must be same type of Asset');
}
},
};
}
function getMostSimilarCandidate(localCandidates, asset) {
const sortedCandidates = localCandidates
.map(candidate => {
const their = compareAssets(candidate, asset);
return {
similarity: their.similarity,
asset: candidate,
};
})
.filter(({ similarity }) => similarity >= 0.6)
.sort((a, b) => b.similarity - a.similarity);
const bestCandidate = sortedCandidates[0];
const maxSimilarity = bestCandidate.similarity;
const candidiatesWithSameMaxSimilarity = sortedCandidates.filter(candidate => candidate.similarity === maxSimilarity);
if (candidiatesWithSameMaxSimilarity.length > 1) {
return {
ambiguousMatchesTitles: candidiatesWithSameMaxSimilarity.map(({ asset }) => asset.title),
};
}
return { bestCandidate, similarity: bestCandidate.similarity };
}
function* iterateMatchables(matchables, asset) {
for (const match of matchables) {
if (match.constructor !== asset.constructor) {
continue;
}
yield match;
}
}
function getMatchByIdFrom(matchables, asset) {
for (const match of matchables) {
if (match.constructor !== asset.constructor) {
continue;
}
if (match.id === asset.id) {
return match;
}
}
}
function idIsUnique(asset) {
return asset.id < 111000001;
}
function getCandidatesByTitleOrDescription({ matchables, asset, onTitleCollision, }) {
const candidates = [];
const candidateTitles = new Set();
for (const matchable of iterateMatchables(matchables, asset)) {
const they = compareAssets(asset, matchable);
if (they.haveSameTitle === false && they.haveSameDescription === false) {
continue;
}
if (onTitleCollision) {
if (candidateTitles.has(matchable.title)) {
onTitleCollision(matchable);
}
candidateTitles.add(matchable.title);
}
candidates.push(matchable);
}
return candidates;
}
function makeMatcher(inputSet, remoteSet) {
// Make sure assets with unique IDs will be encountered first
//
// During matching local assets to new ones, new matchables
// should be removed from the set once they're
// actually matched against something,
// so there are less elements to iterate over when
// new assets are being matched against remote.
const inputMatchables = new Set(Array.from(inputSet).sort((a, b) => {
return Number(idIsUnique(b)) - Number(idIsUnique(a));
}));
const remoteMatchables = new Set(remoteSet);
function matchLocalToInputById(local) {
if (idIsUnique(local) === false) {
return { type: 'skip' };
}
const inputMatch = getMatchByIdFrom(inputMatchables, local);
if (!inputMatch) {
return { type: 'skip' };
}
inputMatchables.delete(inputMatch);
const localAndNew = compareAssets(local, inputMatch);
if (localAndNew.areDifferent || badgeIsSetAgainstLocal(local, inputMatch)) {
const remoteMatch = getMatchByIdFrom(remoteMatchables, inputMatch);
if (!remoteMatch) {
throw new Error(`Input asset ${inputMatch.title} (${inputMatch.id}) matched against local one by ID, ` +
`but there's no match by this ID against remote, that doesn't make sense. ` +
`You may need to refetch remote assets, if that doesn't help - ` +
`specify correct ID or remove asset with invalid ID from local.`);
}
const inputAndRemote = compareAssets(inputMatch, remoteMatch);
remoteMatchables.delete(remoteMatch);
const shouldNotSetBadge = badgeIsSetAgainstRemote(remoteMatch, inputMatch) === false;
if (inputAndRemote.areSame && shouldNotSetBadge) {
return { type: 'delete' };
}
else {
return {
type: 'write',
asset: inputMatch,
old: local,
preserveBadge: shouldNotSetBadge,
};
}
}
return { type: 'keep' };
}
function matchLocalToInputByCode(local) {
// TODO: suspicious about correctness of duplicate code, need tests?
let candidate;
let candidateCode = '';
for (const inputAsset of iterateMatchables(inputMatchables, local)) {
const they = compareAssets(inputAsset, local);
if (they.haveSameCode === false) {
continue;
}
const assetConditions = JSON.stringify(local.conditions);
if (candidate && candidateCode === assetConditions) {
throw new Error(`Local Asset ${local.title} matched against several local ones by having exact same code, ` +
`after failing to match by anything else, that's ambiguous.` +
`Generally you must not have assets with same code.`);
}
candidate = inputAsset;
candidateCode = assetConditions;
}
return candidate;
}
function matchInputToRemoteById(inputAsset) {
const remote = getMatchByIdFrom(remoteMatchables, inputAsset);
if (!remote) {
throw new Error(`Input asset ${inputAsset.title} (${inputAsset.id}) didn't match against anything in local and remote by ID. ` +
`You may need to refetch remote assets, if that doesn't help - ` +
`specify correct ID or remove the ID.`);
}
remoteMatchables.delete(remote);
const they = compareAssets(remote, inputAsset);
const shouldSetBadge = badgeIsSetAgainstRemote(remote, inputAsset);
if (they.areDifferent || shouldSetBadge) {
return {
type: 'write',
asset: inputAsset,
old: remote,
preserveBadge: shouldSetBadge === false,
};
}
else {
return { type: 'skip' };
}
}
return {
localToInput(local) {
const idMatch = matchLocalToInputById(local);
if (idMatch.type !== 'skip') {
return idMatch;
}
const inputCandidates = getCandidatesByTitleOrDescription({
matchables: inputMatchables,
asset: local,
onTitleCollision(asset) {
throw new Error(`Local asset ${asset.title} matched against several input ones by title, that's ambiguous. ` +
`If you absolutely need to deal with assets that have same title - rely on IDs instead.`);
},
});
let inputCandidate;
if (inputCandidates.length > 1) {
const { bestCandidate, ambiguousMatchesTitles } = getMostSimilarCandidate(inputCandidates, local);
if (ambiguousMatchesTitles) {
throw new Error(`Local asset ${local.title} matched against several similar input ones: ` +
ambiguousMatchesTitles.join(', ') +
`; that's ambiguous`);
}
inputCandidate = bestCandidate.asset;
}
else if (inputCandidates.length === 1) {
inputCandidate = inputCandidates[0];
}
else if (inputCandidates.length === 0) {
// Last resort after neither title or description have matched
inputCandidate = matchLocalToInputByCode(local);
}
if (!inputCandidate) {
return { type: 'keep' };
}
inputMatchables.delete(inputCandidate);
const they = compareAssets(inputCandidate, local);
const shouldSetBadge = badgeIsSetAgainstLocal(local, inputCandidate);
if (they.areDifferent || shouldSetBadge) {
return {
type: 'write',
asset: inputCandidate,
old: local,
preserveBadge: idIsUnique(local) &&
inputCandidate instanceof Achievement &&
badgeIsSet(inputCandidate.badge) === false,
};
}
else {
return { type: 'keep' };
}
},
inputToRemote(inputAsset) {
if (idIsUnique(inputAsset)) {
return matchInputToRemoteById(inputAsset);
}
const remoteCandidates = getCandidatesByTitleOrDescription({
matchables: remoteMatchables,
asset: inputAsset,
});
let candidate;
if (remoteCandidates.length > 1) {
const { bestCandidate, ambiguousMatchesTitles } = getMostSimilarCandidate(remoteCandidates, inputAsset);
if (ambiguousMatchesTitles) {
throw new Error(`Asset ${inputAsset.title} matched against several similar remote ones: ` +
ambiguousMatchesTitles.join(', ') +
`; that's ambiguous`);
}
candidate = bestCandidate.asset;
}
else if (remoteCandidates.length === 1) {
candidate = remoteCandidates[0];
}
if (!candidate) {
return { type: 'write', asset: inputAsset, old: null };
}
remoteMatchables.delete(candidate);
const they = compareAssets(candidate, inputAsset);
const shouldSetBadge = badgeIsSetAgainstRemote(candidate, inputAsset);
if (they.areDifferent || shouldSetBadge) {
return {
type: 'write',
asset: inputAsset,
old: candidate,
preserveBadge: shouldSetBadge === false,
};
}
else {
return { type: 'skip' };
}
},
inputMatchables,
};
}
export const availableFilterTypes = new Set(['id', 'title', 'description']);
export function makeAssetFilter(arg) {
let match;
let type;
if ((match = arg.match(/(.+?):/))) {
arg = arg.slice(match[0].length);
type = match[1];
}
else {
throw new Error(`expected filter param to start with 'type:', but got '${arg}'`);
}
if (availableFilterTypes.has(type) === false) {
const correctTypes = [...availableFilterTypes].join(', ');
throw new Error(`expected filter param to have correct type, but got '${type}', correct types are: ${correctTypes}`);
}
if (arg.trim().length === 0) {
const got = arg.length > 0 ? `got whitespace: '${arg}'` : 'got nothing';
throw new Error(`expected filter param to end with value, but ${got}`);
}
if (type === 'id') {
const ids = new Set(arg.split(/,\s*/).map(Number));
return function filter(asset) {
return ids.has(asset.id);
};
}
const regex = new RegExp(arg, 'i');
return function filter(asset) {
return regex.test(asset[type]);
};
}
export function filtersMatch(asset, filters) {
return filters.length === 0 || filters.some(filter => filter(asset));
}
export function calculateSetChanges(inputSet, remoteSet, localData, filters = []) {
const addToLocalByRemoteMatch = [];
const addToLocalFromScratch = [];
const updateInLocal = [];
const removeFromLocal = [];
const keepInLocal = [];
const newLocalFileLines = [];
const getMatchOf = makeMatcher(inputSet, remoteSet);
// I don't fully comprehend max values initialized like that,
// I wish I just set them both to 111000001, without
// doing weird increment after checking local entries
let maxAchLocalId = 111000000;
let maxLbLocalId = 111000000;
const localEntries = localData?.entries || [];
// Local file has to be matched against input set first,
// to calculate the initial auto-incremented id for input assets,
// and to correctly preserve unmatched/bad local assets
for (const local of localEntries) {
if (local.type === 'error') {
newLocalFileLines.push(local.line);
log(chalk.yellowBright(`local file, ignoring line ${local.idx}: ${local.err.message}`));
}
if (local.type === 'achievement' || local.type === 'leaderboard') {
if (idIsUnique(local.asset) === false) {
if (local.type === 'achievement') {
maxAchLocalId = Math.max(maxAchLocalId, local.asset.id);
}
else if (local.type === 'leaderboard') {
maxLbLocalId = Math.max(maxAchLocalId, local.asset.id);
}
}
const match = getMatchOf.localToInput(local.asset);
if (match.type === 'keep') {
newLocalFileLines.push(local.line);
keepInLocal.push(local.asset);
}
if (match.type === 'delete') {
removeFromLocal.push(local.asset);
}
if (match.type === 'write') {
let inputAsset = match.asset;
const preserveRemoteId = idIsUnique(local.asset) && idIsUnique(match.asset) === false;
const preserveOriginalLocalId = idIsUnique(local.asset) === false &&
idIsUnique(match.asset) === false &&
local.asset.id !== match.asset.id;
if (preserveRemoteId || preserveOriginalLocalId) {
inputAsset = match.asset.with({ id: local.asset.id });
}
if (match.preserveBadge &&
match.old instanceof Achievement &&
inputAsset instanceof Achievement) {
inputAsset = inputAsset.with({ badge: match.old.badge });
}
if (filtersMatch(inputAsset, filters)) {
newLocalFileLines.push(inputAsset.toString());
updateInLocal.push({ original: local.asset, modified: inputAsset });
}
else {
newLocalFileLines.push(local.line);
}
}
}
}
// Wish I could get rid of this, see init of these above
maxAchLocalId++;
maxLbLocalId++;
// Now whatever unmatched new assets have
// to be matched against remote ones
const { inputMatchables } = getMatchOf;
for (const asset of inputMatchables) {
const match = getMatchOf.inputToRemote(asset);
if (match.type === 'write') {
const matchedAgainstRemote = Boolean(match.old);
let inputAsset = match.asset;
if (matchedAgainstRemote) {
if (match.old instanceof Achievement && match.asset instanceof Achievement) {
inputAsset = asset.with({
id: match.old.id,
badge: match.preserveBadge ? match.old.badge : match.asset.badge,
});
}
else {
inputAsset = asset.with({ id: match.old.id });
}
}
else {
inputAsset =
inputAsset instanceof Achievement
? inputAsset.with({ id: maxAchLocalId++ })
: inputAsset.with({ id: maxLbLocalId++ });
}
if (filtersMatch(inputAsset, filters)) {
newLocalFileLines.push(inputAsset.toString());
if (match.old) {
addToLocalByRemoteMatch.push({
original: match.old,
modified: inputAsset,
});
}
else {
addToLocalFromScratch.push(inputAsset);
}
}
}
}
return {
addToLocalByRemoteMatch,
addToLocalFromScratch,
removeFromLocal,
updateInLocal,
keepInLocal,
newLocalFileLines,
};
}