UNPKG

@cruncheevos/core

Version:

Parse and generate achievements and leaderboards for RetroAchievements.org

259 lines (258 loc) 9.83 kB
import { normalizedConditionGroupSet, validateRegularMeasuredConditions, } from './condition.js'; import { eatSymbols, isObject, parseCSV, quoteIfHaveTo, deepFreeze, wrappedError, capitalizeWord, validate as commonValidate, indexToConditionGroupName, } from './util.js'; const allowedLeaderboardConditionGroups = new Set(['start', 'cancel', 'submit', 'value']); const allowedLeaderboardTypes = new Set([ 'SCORE', 'TIME', 'FRAMES', 'MILLISECS', 'SECS', 'TIMESECS', 'MINUTES', 'SECS_AS_MINS', 'VALUE', 'UNSIGNED', 'TENS', 'HUNDREDS', 'THOUSANDS', 'FIXED1', 'FIXED2', 'FIXED3', ]); const validate = { andNormalizeLeaderboardId(id) { if (typeof id === 'string') { if (id.startsWith('L')) { id = id.slice(1); } else { throw new Error(`expected id to start with L, but got "${id}"`); } } return commonValidate.andNormalizeId(id); }, andNormalizeConditions(conditions) { let result; if (typeof conditions === 'string') { result = leaderboardConditionsFromLegacyString(conditions); } else if (isObject(conditions)) { result = Object.keys(conditions).reduce((obj, key) => { if (allowedLeaderboardConditionGroups.has(key) === false) { throw new Error(`expected leaderboard condition group name to be one of: [${[ ...allowedLeaderboardConditionGroups, ].join(', ')}], but got ` + eatSymbols `${key}`); } obj[key] = normalizedConditionGroupSet(conditions[key], { considerLegacyValueFormat: key === 'value', }); return obj; }, {}); } else { throw new Error(eatSymbols `expected conditions to be an object, but got ${conditions}`); } for (const group of result.value) { const hasMeasuredFlag = group.some(x => x.flag === 'Measured'); if (hasMeasuredFlag === false) { for (let i = 0; i < group.length; i++) { const condition = group[i]; if (condition.flag === '') { group[i] = condition.with({ flag: 'Measured' }); break; } } } } return result; }, leaderboardType(type) { if (allowedLeaderboardTypes.has(type) === false) { throw new Error(`expected type to be one of: [${[...allowedLeaderboardTypes].join(', ')}], but got ` + eatSymbols `${type}`); } }, measuredConditions(conditions) { for (const group of ['start', 'cancel', 'submit', 'value']) { try { if (group !== 'value') { validateRegularMeasuredConditions(conditions[group]); } validate.lackOfMeasuredPercent(conditions[group]); } catch (err) { throw wrappedError(err, `${capitalizeWord(group)}, ` + err.message); } } }, lackOfMeasuredPercent(conditions) { conditions.forEach((group, groupIndex) => { group.forEach((condition, conditionIndex) => { if (condition.flag === 'Measured%') { const groupName = indexToConditionGroupName(groupIndex); throw new Error(`${groupName}, condition ${conditionIndex + 1}: Measured% conditions are not allowed in leaderboards`); } }); }); }, andNormalizeLowerIsBetter(lowerIsBetter) { if (typeof lowerIsBetter === 'string') { return lowerIsBetter.length > 0 && lowerIsBetter !== '0'; } else if (typeof lowerIsBetter === 'boolean') { return lowerIsBetter; } else { throw new Error(eatSymbols `expected lowerIsBetter as boolean or string, but got ${lowerIsBetter}`); } }, }; function conditionsToString(group, separator = 'S') { return group.map(x => x.map(x => x.toString()).join('_')).join(separator); } function leaderboardFromString(str) { const col = parseCSV(str); if (col.length !== 9) { throw new Error(`got an unexpected amount of data when parsing raw leaderboard string, either there's not enough data or it's not escaped/quoted correctly`); } const def = { id: validate.andNormalizeLeaderboardId(col[0]), conditions: validate.andNormalizeConditions({ start: col[1], cancel: col[2], submit: col[3], value: col[4], }), type: col[5], title: col[6], description: col[7], lowerIsBetter: validate.andNormalizeLowerIsBetter(col[8]), }; return def; } const moveConditions = Symbol(); /** * This class represents a leaderboard for RetroAchievements. Leaderboards can be a part of AchievementSet class instances, or used separately if your goal is to parse and produce string representations of leaderboard that would go into local RACache file. * * Leaderboard are immutable, if you need to a make a new Leaderboard instance based of existing one - use `with()` method. */ export class Leaderboard { constructor(def) { const isLeaderboardInstance = def instanceof Leaderboard; if (typeof def === 'string') { Object.assign(this, leaderboardFromString(def)); } else if (isObject(def) && isLeaderboardInstance === false) { let conditions = def.conditions; if (!def[moveConditions]) { conditions = validate.andNormalizeConditions(def.conditions); } Object.assign(this, { ...def, id: validate.andNormalizeLeaderboardId(def.id), title: def.title, description: def.description, type: def.type, lowerIsBetter: validate.andNormalizeLowerIsBetter(def.lowerIsBetter), conditions, }); } else { throw new Error('leaderboard data must be an object or string with leaderboard code, but got ' + (isLeaderboardInstance ? 'another Leaderboard instance' : eatSymbols `${def}`)); } commonValidate.title(this.title); this.description = commonValidate.andNormalizeDescription(this.description); validate.leaderboardType(this.type); validate.measuredConditions(this.conditions); deepFreeze(this); } /** * Returns new Leaderboard instance with different values merged. * * @param {DeepPartial<Leaderboard.InputObject>} data DeepPartial<Leaderboard.InputObject> * * @example * someLeaderboard * .with({ title: someLeaderboard.title + 'suffix' }) */ with(data) { return new Leaderboard({ ...this, ...data, [moveConditions]: data.hasOwnProperty('conditions') === false, }); } /** * Returns string representation of Leaderboard suitable * for `RACache/Data/GameId-User.txt` file. * * @param desiredData optional parameter, set this to `'leaderboard'` or `'conditions'` to have corresponding string returned. Default option is `'leaderboard'`. * * @example * * someLeaderboard.toString() * someLeaderboard.toString('leaderboard') * // 'L58:"0xHfff0=1S":"0=1":"1=1":"M:0xX34440*2":SCORE:My Leaderboard:Best score while doing something funny:0' * * someLeaderboard.toString('conditions') // '"0xHfff0=1S":"0=1":"1=1":"M:0xX34440*2"' */ toString(desiredData = 'leaderboard') { const conditions = [ conditionsToString(this.conditions.start), conditionsToString(this.conditions.cancel), conditionsToString(this.conditions.submit), conditionsToString(this.conditions.value, '$'), ].map(x => `"${x}"`); if (desiredData === 'conditions') { return conditions.join(':'); } else if (desiredData === 'leaderboard') { let res = ''; res += 'L' + this.id + ':'; res += conditions.join(':') + ':'; res += this.type + ':'; res += quoteIfHaveTo(this.title) + ':'; res += quoteIfHaveTo(this.description) + ':'; res += Number(this.lowerIsBetter); return res; } else { throw new Error(eatSymbols `unexpected leaderboard data toString request: ${desiredData}`); } } } function leaderboardConditionsFromLegacyString(str) { const conditions = { start: null, cancel: null, submit: null, value: null, }; let match = null; for (const [rawKey, key] of [ ['STA', 'start'], ['CAN', 'cancel'], ['SUB', 'submit'], ['VAL', 'value'], ]) { const isValue = rawKey === 'VAL'; // TODO: cache regexps if ((match = str.match(new RegExp(isValue ? /VAL:(.+)/ : `${rawKey}:(.+?)::`)))) { str = str.slice(match[0].length); try { conditions[key] = normalizedConditionGroupSet(match[1], { considerLegacyValueFormat: isValue, }); } catch (err) { // TODO: this error message needs tests throw wrappedError(err, `${capitalizeWord(key)}, ${err.message}`); } } else { throw new Error(`expected ${rawKey}:<conditions>::, but got ${str.slice(0, 6)}`); } } return conditions; }