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