UNPKG

@cruncheevos/core

Version:

Parse and generate achievements and leaderboards for RetroAchievements.org

216 lines (215 loc) 9.4 kB
import { normalizedConditionGroupSet, normalizedConditionGroupSetFromString, validateRegularMeasuredConditions, } from './condition.js'; import { deepFreeze, eatSymbols, isNumber, isObject, parseCSV, quoteIfHaveTo, validate as commonValidate, indexToConditionGroupName, } from './util.js'; const allowedAchievementTypesForDisplay = ['missable', 'progression', 'win_condition']; const allowedAchievementTypes = new Set(['', ...allowedAchievementTypesForDisplay]); const validate = { points(points) { if (isNumber(points, { isInteger: true, isPositive: true }) === false) { throw new Error(`expected points value to be a positive integer, but got ` + eatSymbols `${points}`); } }, measuredConditionsMixing(conditions) { const measuredConditions = []; const measuredPercentConditions = []; conditions.forEach((group, groupIndex) => { const groupName = indexToConditionGroupName(groupIndex); group.forEach((condition, conditionIndex) => { if (condition.flag === 'Measured') { measuredConditions.push([groupName, conditionIndex]); } if (condition.flag === 'Measured%') { measuredPercentConditions.push([groupName, conditionIndex]); } }); }); if (measuredPercentConditions.length > 0 && measuredConditions.length > 0) { const m = measuredConditions[0]; const mp = measuredPercentConditions[0]; throw new Error(`${m[0]}, condition ${m[1] + 1}: Measured conflicts with ${mp[0]}, condition ${mp[1] + 1} Measured%, ` + `make sure you exclusively use Measured or Measured%`); } }, andNormalizeAuthor(author) { if (author === undefined || author === null) { author = ''; } if (typeof author !== 'string') { throw new Error(eatSymbols `expected author as string, but got ${author}`); } return author || 'cruncheevos'; }, andNormalizeAchievementType(type) { type = type === undefined ? '' : type; if (allowedAchievementTypes.has(type) === false) { throw new Error(`expected type to be one of: [${[...allowedAchievementTypesForDisplay].join(', ')}], or empty string, or undefined, but got ` + eatSymbols `${type}`); } return type; }, andNormalizeBadge(badge) { const errMessage = eatSymbols `expected badge as unsigned integer or filepath starting with local\\\\ and going strictly down, but got ${badge}`; if (badge === undefined || badge === null) { return '00000'; } if (isNumber(badge, { isInteger: true })) { const num = Number(badge); if (num < 0 || num > 0xffffffff) { throw new Error(`expected badge id to be within the range of 0x0 .. 0xFFFFFFFF, but got ${badge}`); } return badge.toString().padStart(5, '0'); } else if (typeof badge === 'string') { const pieces = badge.split('\\\\'); if (pieces.length < 2 || pieces[0] !== 'local') { throw new Error(errMessage); } for (const piece of pieces) { if (/^\.+$/.test(piece)) { throw new Error(`encountered ${piece} within ${badge}, path can only go down`); } } const fileName = pieces[pieces.length - 1]; if (/^.+\.(png|jpe?g|gif)$/.test(fileName) === false) { throw new Error(`expected badge filename to be *.(png|jpg|jpeg|gif) but got "${fileName}"`); } return badge; } else { throw new Error(errMessage); } }, }; const entireLineIsWhitespace = /^\s+$/; function achievementDataFromString(str) { const col = parseCSV(str); if (col.length !== 13 && col.length !== 14) { throw new Error(`got an unexpected amount of data when parsing raw achievement string, either there's not enough data or it's not escaped/quoted correctly`); } let type = col[6]; if (type.match(entireLineIsWhitespace)) { type = ''; } const [id, setId] = col[0].split('|'); const def = { id: commonValidate.andNormalizeId(id), setId: setId === undefined ? setId : commonValidate.andNormalizeId(setId, 'setId'), title: col[2], description: col[3], type, author: col[7], points: Number(col[8]), badge: validate.andNormalizeBadge(col[13] || ''), conditions: normalizedConditionGroupSetFromString(col[1]), }; return def; } const moveConditions = Symbol(); /** * This class represents an achievement for RetroAchievements. Achievement can be a part of AchievementSet class instance, or used separately if your goal is to parse and produce string representations of achievement that would go into local RACache file. * * Achievements are immutable, if you need to a make a new Achievement instance based of existing one - use `with()` method. */ export class Achievement { constructor(def) { const isAchievementInstance = def instanceof Achievement; if (typeof def === 'string') { Object.assign(this, achievementDataFromString(def)); } else if (isObject(def) && isAchievementInstance === false) { let conditions = def.conditions; if (!def[moveConditions]) { conditions = normalizedConditionGroupSet(def.conditions); } Object.assign(this, { id: commonValidate.andNormalizeId(def.id), setId: def.setId === undefined ? def.setId : commonValidate.andNormalizeId(def.setId, 'setId'), title: def.title, description: def.description, author: def.author, points: def.points, type: def.type, badge: validate.andNormalizeBadge(def.badge), conditions, }); } else { throw new Error('achievement data must be an object or string with achievement code, but got ' + (isAchievementInstance ? 'another Achievement instance' : eatSymbols `${def}`)); } commonValidate.string(this.title, 'title'); commonValidate.string(this.description, 'description'); this.author = validate.andNormalizeAuthor(this.author); validate.points(this.points); this.type = validate.andNormalizeAchievementType(this.type); validateRegularMeasuredConditions(this.conditions); validate.measuredConditionsMixing(this.conditions); deepFreeze(this); } /** * Returns new Achievement instance with different values merged. * * @param {DeepPartial<Achievement.InputObject>} data DeepPartial<Achievement.InputObject> * * @example * someAchievement * .with({ title: someAchievement.title + 'suffix' }) */ with(data) { return new Achievement({ ...this, ...data, [moveConditions]: data.hasOwnProperty('conditions') === false, }); } /** * Returns string representation of Achievement suitable * for `RACache/Data/GameId-User.txt` file. * * @param desiredData optional parameter, set this to `'achievement'`, * `'achievement-legacy'` or `'conditions'` to have corresponding string returned. * `'achievement-legacy'` will omit `setId` from the output. Default option is `'achievement'`. * * @example * * someAchievement.toString() * someAchievement.toString('achievement') * // '58:"0=1":My Achievement:Do something funny::::cruncheevos:5:::::00000' * * someAchievement.toString('conditions') // '0=1' * * // if setId is set * someAchievement.toString() * someAchievement.toString('achievement') * // '58|1024:"0=1":My Achievement:Do something funny::::cruncheevos:5:::::00000' * * someAchievement.toString('achievement-legacy') * // '58:"0=1":My Achievement:Do something funny::::cruncheevos:5:::::00000' */ toString(desiredData = 'achievement') { const conditions = this.conditions.map(x => x.map(x => x.toString()).join('_')).join('S'); if (desiredData === 'conditions') { return conditions; } else if (desiredData === 'achievement' || desiredData === 'achievement-legacy') { let res = ''; res += this.id; if (desiredData === 'achievement' && this.setId !== undefined) { res += '|' + this.setId; } res += ':'; res += `"${conditions}"` + ':'; res += quoteIfHaveTo(this.title) + ':'; res += quoteIfHaveTo(this.description); res += ':::'; res += this.type + ':'; res += quoteIfHaveTo(this.author) + ':'; res += this.points; res += ':::::'; res += this.badge.startsWith('local\\\\') ? `"${this.badge}"` : this.badge; return res; } else { throw new Error(eatSymbols `unexpected achievement data toString request: ${desiredData}`); } } }