@cruncheevos/core
Version:
Parse and generate achievements and leaderboards for RetroAchievements.org
216 lines (215 loc) • 9.4 kB
JavaScript
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}`);
}
}
}