UNPKG

@cruncheevos/cli

Version:

Maintain achievement sets for RetroAchievements.org using JavaScript, an alternative to RATools

351 lines (350 loc) 14.5 kB
import jsonDiff from 'json-diff'; import { table } from 'table'; import chalk from 'chalk'; import * as path from 'path'; import { achievementSetImport, log, resolveRACache } from './mockable.js'; import { Condition, Leaderboard, Achievement } from '@cruncheevos/core'; import { capitalizeWord } from '@cruncheevos/core/util'; import { extractAchievementSetFromModule, calculateSetChanges, getLocalData, } from './util.js'; import { getSetFromRemote } from './command-fetch.js'; import { logWarnings } from './lint.js'; const diffGapDelimeter = '······'; function makeConditionComparisonReport(opts) { let longestNumberWidth = 0; const initialDiff = jsonDiff.diff(opts.original.map(x => x.toString()), opts.modified.map(x => x.toString()), { full: true, }); // If it returns array of strings only, // then there were no changes, that's how library works if (initialDiff.some(x => Array.isArray(x) === false)) { return { report: [], longestNumberWidth }; } let left = 0; let right = 0; const changedLinesIndexes = []; let res = initialDiff.map(([type, data], i) => { if (type === '+' || type === '-') { changedLinesIndexes.push(i); longestNumberWidth = Math.max(longestNumberWidth, i.toString().length); } // prettier-ignore return [ type === '+' ? '+' : ++left, type === '-' ? '-' : ++right, data ]; }); if (res.length > 10 || opts.contextLines) { const length = Math.min(opts.contextLines || 1, initialDiff.length); // line = 5, length = 2 -> [ 3, 4, 5, 6, 7 ] const linesToInclude = new Set(changedLinesIndexes.flatMap(idx => Array.from({ length: length * 2 + 1 }, (_, i) => i + idx - length))); res = res.filter((_, i) => linesToInclude.has(i)); } res = res.map(x => [x[0], x[1], ...new Condition(x[2]).toArrayPretty()]); // inject gaps between lines for (let i = res.length - 1; i >= 1; i--) { const leftDiff = res[i][0] - res[i - 1][0]; const rightDiff = res[i][1] - res[i - 1][1]; if (leftDiff > 1 && rightDiff > 1) { res.splice(i, 0, [diffGapDelimeter, '', '', '', '', '', '', '', '', '', '']); } } res.unshift(['', '', 'Flag', 'Type', 'Size', 'Value', 'Cmp', 'Type', 'Size', 'Value', 'Hits']); return { report: res, longestNumberWidth }; } function makeConditionGroupReports(opts) { let longestNumberWidth = 2; const { original, modified, contextLines } = opts; const conditionGroupReports = []; const isAchievement = original instanceof Achievement && modified instanceof Achievement; if (isAchievement) { const maxConditionGroupCount = Math.max(original.conditions.length, modified.conditions.length); for (let i = 0; i < maxConditionGroupCount; i++) { const res = makeConditionComparisonReport({ original: original.conditions[i] || [], modified: modified.conditions[i] || [], contextLines, }); longestNumberWidth = Math.max(longestNumberWidth, res.longestNumberWidth); if (res.report.length > 0) { if (!original.conditions[i] && modified.conditions[i].length === 0) { res.report.push([' ', '+', 'no conditions', '', '', '', '', '', '', '', '']); } else if (!modified.conditions[i] && original.conditions[i].length === 0) { res.report.push(['-', ' ', 'no conditions', '', '', '', '', '', '', '', '']); } conditionGroupReports.push([i === 0 ? 'Core' : `Alt ${i}`, res.report]); } } } else { for (const group of ['start', 'cancel', 'submit', 'value']) { const maxConditionGroupCount = Math.max(original.conditions[group].length, modified.conditions[group].length); for (let i = 0; i < maxConditionGroupCount; i++) { const res = makeConditionComparisonReport({ original: original.conditions[group][i] || [], modified: modified.conditions[group][i] || [], contextLines, }); longestNumberWidth = Math.max(longestNumberWidth, res.longestNumberWidth); /* Group Header examples: Start - Core Cancel - Alt 1 Submit - Alt 2 Value (not Value - Core!) Value - Alt 1 */ if (res.report.length > 0) { const groupNameHeaderPieces = [capitalizeWord(group)]; if (i > 0) { groupNameHeaderPieces.push(`Alt ${i}`); } else if (group !== 'value') { groupNameHeaderPieces.push(`Core`); } conditionGroupReports.push([groupNameHeaderPieces.join(' - '), res.report]); } } } } return { conditionGroupReports, firstColumnWidth: conditionGroupReports.length > 0 ? longestNumberWidth : 3, }; } function colorTheLine(line, color) { return line.replace(/^(.+)(│)(.+)$/, (_, left, border, right) => { return chalk[color](left) + border + chalk[color](right); }); } function makeAssetDiffReport(opts) { const lines = []; const spanningCells = []; function pushHeaderLines(...incomingLines) { for (const [header, value] of incomingLines) { const row = lines.length; spanningCells.push({ row, col: 0, colSpan: 2, alignment: 'right' }); spanningCells.push({ row, col: 2, colSpan: 9, alignment: 'left' }); lines.push([header, '', value, '', '', '', '', '', '', '', '']); } } const { original, modified, comparisonContext } = opts; // Deal with changes other than code const isAchievement = original instanceof Achievement && modified instanceof Achievement; const isLeaderboard = original instanceof Leaderboard && modified instanceof Leaderboard; const titleChanged = original.title !== modified.title; const achievementBadgeChanged = isAchievement && original.badge !== modified.badge; const descriptionChanged = original.description !== modified.description; const achievementTypeChanged = isAchievement && original.type !== modified.type; const achievementPointsChanged = isAchievement && original.points !== modified.points; const leaderboardTypeChanged = isLeaderboard && original.type !== modified.type; const leaderboardLowerIsBetterChanged = isLeaderboard && original.lowerIsBetter !== modified.lowerIsBetter; pushHeaderLines([ isAchievement ? 'A.ID' : 'L.ID', original.id.toString() + ` (${comparisonContext})`, ]); if (titleChanged) { pushHeaderLines(['Title', chalk.redBright('- ' + original.title)], ['', chalk.greenBright('+ ' + modified.title)]); } else { pushHeaderLines(['Title', original.title]); } // Don't show description if title didn't change, ID and title is enough if (titleChanged && descriptionChanged === false) { pushHeaderLines(['Desc.', original.description]); } else if (descriptionChanged) { pushHeaderLines(['Desc.', chalk.redBright('- ' + original.description)], ['', chalk.greenBright('+ ' + modified.description)]); } if (achievementTypeChanged) { const originalType = capitalizeWord(original.type || 'none'); const modifiedType = capitalizeWord(modified.type || 'none'); pushHeaderLines([ 'Type', chalk.redBright(originalType) + ' -> ' + chalk.greenBright(modifiedType), ]); } if (achievementPointsChanged) { pushHeaderLines([ 'Pts.', chalk.redBright(original.points) + ' -> ' + chalk.greenBright(modified.points), ]); } if (achievementBadgeChanged) { pushHeaderLines([ 'Badge', chalk.redBright(original.badge) + ' -> ' + chalk.greenBright(modified.badge), ]); } if (leaderboardTypeChanged) { pushHeaderLines([ 'Type', chalk.redBright(original.type) + ' -> ' + chalk.greenBright(modified.type), ]); } if (leaderboardLowerIsBetterChanged) { pushHeaderLines([ 'Low?', chalk.redBright(original.lowerIsBetter.toString()) + ' -> ' + chalk.greenBright(modified.lowerIsBetter.toString()), ]); } // Deal with code changes const { conditionGroupReports, firstColumnWidth } = makeConditionGroupReports(opts); if (conditionGroupReports.length === 0 && titleChanged === false && descriptionChanged === false && achievementPointsChanged === false && achievementBadgeChanged === false && achievementTypeChanged === false && leaderboardTypeChanged === false && leaderboardLowerIsBetterChanged === false) { return ''; } for (const [groupName, codeLines] of conditionGroupReports) { pushHeaderLines(['Code', groupName]); codeLines.forEach(line => { lines.push(line); if (line[0] === '······') { spanningCells.push({ row: lines.length - 1, col: 0, colSpan: 2, alignment: 'center' }); } if (line[2] === 'no conditions') { spanningCells.push({ row: lines.length - 1, col: 2, colSpan: 8 }); } }); } // Now format the table const formattedTable = table(lines, { columnDefault: { paddingRight: 0, }, columns: { /* Header title half */ 0: { paddingRight: 1, alignment: 'right', width: firstColumnWidth, }, /* Header title half */ 1: { paddingRight: 0, alignment: 'right', }, ...(conditionGroupReports.length > 0 ? {} : { /* Header value spanning */ 2: { width: 40 }, }), /* lvalue.value */ 5: { alignment: 'right', }, /* cmp */ 6: { alignment: 'center', }, /* rvalue.value */ 9: { alignment: 'right', }, /* hits */ 10: { alignment: 'right', }, }, spanningCells, drawHorizontalLine: i => lines[i + 1]?.[2] === 'Flag' || lines[i - 1]?.[2] === 'Flag', drawVerticalLine: i => i === 2, }); return ('\n' + formattedTable .split('\n') .map(x => { if (x.match(/^\s*\+/)) { return colorTheLine(x, 'greenBright'); } if (x.match(/^\s*\d+\s+-/) || x.includes('- │ no conditions')) { return colorTheLine(x, 'redBright'); } return x; }) .map(x => x.trimEnd()) .join('\n')); } export function diffExecute({ changes, contextLines, }) { let result = ''; const [newAchievementTitles, newLeaderboardTitles] = [Achievement, Leaderboard].map(classDef => changes.addToLocalFromScratch .filter(x => x instanceof classDef) .map(x => x.title) .sort((a, b) => a.localeCompare(b))); if (newAchievementTitles.length > 0) { result += `New achievements added:\n`; for (const title of newAchievementTitles) { result += ' ' + title + '\n'; } result += '\n'; } if (newLeaderboardTitles.length > 0) { result += `New leaderboards added:\n`; for (const title of newLeaderboardTitles) { result += ' ' + title + '\n'; } result += '\n'; } let assetsChangedString = ''; for (const { original, modified } of changes.updateInLocal) { assetsChangedString += makeAssetDiffReport({ original, modified, contextLines, comparisonContext: 'compared to local', }); } for (const { original, modified } of changes.addToLocalByRemoteMatch) { assetsChangedString += makeAssetDiffReport({ original, modified, contextLines, comparisonContext: 'compared to remote', }); } if (assetsChangedString.length > 0) { result += `Assets changed:\n`; result += assetsChangedString; } log(result || 'no changes found'); return { hasChanges: Boolean(result) }; } export default async function diff(inputFilePath, opts) { const { refetch, excludeUnofficial, contextLines, filter = [], timeout = 1000 } = opts; const absoluteModulePath = path.resolve(inputFilePath); const module = await achievementSetImport(absoluteModulePath); const inputSet = await extractAchievementSetFromModule(module, absoluteModulePath); const { gameId } = inputSet; const achievementCount = Object.keys(inputSet.achievements).length; const leaderboardCount = Object.keys(inputSet.leaderboards).length; const inputSetIsEmpty = achievementCount === 0 && leaderboardCount === 0; if (inputSetIsEmpty) { log(chalk.yellowBright(`set doesn't define any achievements or leaderboards, diff aborted`)); return { hasChanges: false }; } try { var remoteSet = await getSetFromRemote({ gameId, excludeUnofficial, refetch, timeout }); } catch (err) { log(chalk.redBright(`remote data got issues, cannot proceed with the diff`)); throw err; } try { var localData = getLocalData({ gameId, throwOnFirstError: false, }); } catch (err) { log(chalk.yellowBright(`local file got issues, will not diff against local file`)); throw err; } if (!localData) { const filePath = resolveRACache(`./RACache/Data/${gameId}-User.txt`); log(chalk.yellowBright(`local file ${filePath} doesn't exist, will not diff against local file`)); } const changes = calculateSetChanges(inputSet, remoteSet, localData, filter); diffExecute({ changes, contextLines }); logWarnings(inputSet); }