@cruncheevos/core
Version:
Parse and generate achievements and leaderboards for RetroAchievements.org
243 lines (242 loc) • 8.71 kB
JavaScript
import { Achievement } from './achievement.js';
import { Leaderboard } from './leaderboard.js';
import { validate } from './util.js';
const privateMap = new WeakMap();
function* iterateObject() {
for (const key in this) {
yield this[key];
}
}
/**
* This class represents AchievementSet that can be converted into
* RACache/Data/GameId-User.txt file
*
* AchievementSet is mostly to be used with standalone scripts that export it
* for `@cruncheevos/cli` to update local file in RACache.
*/
export class AchievementSet {
/**
* Creates AchievementSet.
*
* @example
* new AchievementSet({ gameId: 1234, title: 'Funny Game' })
*/
constructor(opts) {
this.achievements = {
[Symbol.iterator]: iterateObject,
};
this.leaderboards = {
[Symbol.iterator]: iterateObject,
};
const { gameId, id, title } = opts;
this.gameId = validate.andNormalizeId(gameId, 'gameId');
if (id !== undefined) {
this.id = validate.andNormalizeId(id, 'id');
}
validate.nonEmptyString(title, 'achievement set title');
this.title = title;
privateMap.set(this, {
achievementIdCounter: 111000001,
leaderboardIdCounter: 111000001,
});
}
/**
* Adds Achievement to the set, accepts same data as {@link Achievement} class constructor,
* but you're allowed to omit id when passing an object (id will be assigned automatically, similar to how RAIntegration does it).
*
* Also returns current AchievementSet instance, allowing you to chain calls.
*
* @example
* import { AchievementSet, define as $ } from '@cruncheevos/core'
*
* const set = new AchievementSet({ gameId: 1234, title: 'Funny Game' })
*
* set.addAchievement({
* id: 58, // optional, or numeric string
* title: 'My Achievement',
* description: 'Do something funny',
* points: 5,
* badge: `local\\\\my_achievement.png`, // optional, or ID of badge on server
* author: 'peepy', // optional and is not uploaded to server
* conditions: {
* core: [
* ['', 'Mem', '8bit', 0x00fff0, '=', 'Value', '', 0],
* ['', 'Mem', '8bit', 0x00fffb, '=', 'Value', '', 0],
* ],
* alt1: $(
* ['', 'Mem', '8bit', 0x00fe10, '>', 'Delta', '8bit', 0x00fe10],
* ['', 'Mem', '8bit', 0x00fe11, '=', 'Value', '', 0],
* ),
* alt2: '0=1'
* }
* }).addAchievement(...)
*/
addAchievement(def) {
const privateData = privateMap.get(this);
// prettier-ignore
let ach = def instanceof Achievement ? def : new Achievement(typeof def === 'string' ? def : {
...def,
id: def.id || privateData.achievementIdCounter,
});
if (this.id !== ach.setId) {
ach = ach.with({ setId: this.id });
}
const { id } = ach;
if (this.achievements[id]) {
throw new Error(`achievement with id ${id}: "${this.achievements[id].title}", already exists`);
}
this.achievements[id] = ach;
if (ach.id >= privateData.achievementIdCounter) {
privateData.achievementIdCounter = Math.max(privateData.achievementIdCounter + 1, ach.id + 1);
}
return this;
}
/**
* Adds Leaderboard to the set, accepts same data as {@link Leaderboard} class constructor,
* but you're allowed to omit id when passing an object (id will be assigned automatically, similar to how RAIntegration does it).
*
* Also returns current AchievementSet instance, allowing you to chain calls.
*
* @example
* import { AchievementSet, define as $ } from '@cruncheevos/core'
*
* const set = new AchievementSet({ gameId: 1234, title: 'Funny Game' })
*
* set.addLeaderboard({
* id: 58, // optional, or numeric string
* title: 'My Leaderboard',
* description: 'Best score while doing something funny',
* type: 'SCORE',
* lowerIsBetter: false,
* conditions: {
* start: {
* core: [
* ['', 'Mem', '8bit', 0x00fff0, '=', 'Value', '', 0],
* ['', 'Mem', '8bit', 0x00fffb, '=', 'Value', '', 0],
* ],
* alt1: $(
* ['', 'Mem', '8bit', 0x00fe10, '>', 'Delta', '8bit', 0x00fe10],
* ['', 'Mem', '8bit', 0x00fe11, '=', 'Value', '', 0],
* ),
* alt2: '0=1',
* },
* cancel: [
* ['', 'Mem', '16bit', 0x34684, '=', 'Value', '', 0x140]
* ], // same as providing an object: { core: [ ... ] }
* submit: '0xH59d76=2',
* value: [['Measured', 'Mem', '32bit', 0x34440, '*', 'Value', '', 2]],
* },
* }).addLeaderboard(...)
*/
addLeaderboard(def) {
const privateData = privateMap.get(this);
// prettier-ignore
let lb = def instanceof Leaderboard ? def : new Leaderboard(typeof def === 'string' ? def : {
...def,
id: def.id || privateData.leaderboardIdCounter,
});
if (this.id !== lb.setId) {
lb = lb.with({ setId: this.id });
}
const { id } = lb;
if (this.leaderboards[id]) {
throw new Error(`leaderboard with id ${id}: "${this.leaderboards[id].title}", already exists`);
}
this.leaderboards[id] = lb;
if (lb.id >= privateData.leaderboardIdCounter) {
privateData.leaderboardIdCounter = Math.max(privateData.leaderboardIdCounter + 1, lb.id + 1);
}
return this;
}
/**
* Allows to iterate the whole set for both achievements and leaderboards.
*
* @example
* for (const asset of achSet) {
* if (asset instanceof Achievement) {
* // ...
* }
* if (asset instanceof Leaderboard) {
* // ...
* }
* }
*/
*[Symbol.iterator]() {
for (const ach of this.achievements) {
yield ach;
}
for (const lb of this.leaderboards) {
yield lb;
}
}
/**
* Returns string representation of AchievementSet suitable for
* `RACache/Data/GameId-User.txt` file.
*
* First line is version, always set to 1.0, second line is game's title.
* Then come string representations of achievements and leaderboards,
* each sorted by id.
*
* @param desiredData optional parameter, set this to `'set'` or `'set-legacy'`.
* `'set-legacy'` will omit `id` from the output. Default option is `'set'`
*
* @example
* new AchievementSet({ gameId: 1234, title: 'Funny Game' })
* .addAchievement(...)
* .addAchievement(...)
* .addLeaderboard(...)
* .addLeaderboard(...)
* .toString()
* // may result in:
* `
* 1.0
* Funny Game
* 57:"0x cafe=102":Ach2:Desc2::::cruncheevos:2:::::00000
* 111000001:"0x cafe=101":Ach1:Desc1::::cruncheevos:1:::::00000
* L58:"0x cafe=102":"0=1":"1=1":"M:0x feed":FRAMES:Lb2:Desc2:1
* L111000001:"0x cafe=101":"0=1":"1=1":"M:0x feed":SCORE:Lb1:Desc1:0
* `
*
* new AchievementSet({ gameId: 1234, id: 5800, title: 'Funny Game' })
* .addAchievement(...)
* .addLeaderboard(...)
* .toString()
* // may result in:
* `
* 1.0
* Funny Game
* 57|5800:"0x cafe=102":Ach2:Desc2::::cruncheevos:2:::::00000
* L58|5800:"0x cafe=102":"0=1":"1=1":"M:0x feed":FRAMES:Lb2:Desc2:1
* `
*
* new AchievementSet({ gameId: 1234, id: 5800, title: 'Funny Game' })
* .addAchievement(...)
* .addLeaderboard(...)
* .toString('set-legacy')
* // may result in:
* `
* 1.0
* Funny Game
* 57:"0x cafe=102":Ach2:Desc2::::cruncheevos:2:::::00000
* L58:"0x cafe=102":"0=1":"1=1":"M:0x feed":FRAMES:Lb2:Desc2:1
* `
*/
toString(desiredData = 'set') {
let res = '';
res += '1.0\n';
res += this.title + '\n';
Object.keys(this.achievements)
.sort((a, b) => Number(a) - Number(b))
.forEach(key => {
res +=
this.achievements[key].toString(desiredData === 'set-legacy' ? 'achievement-legacy' : 'achievement') + '\n';
});
Object.keys(this.leaderboards)
.sort((a, b) => Number(a) - Number(b))
.forEach(key => {
res +=
this.leaderboards[key].toString(desiredData === 'set-legacy' ? 'leaderboard-legacy' : 'leaderboard') + '\n';
});
return res;
}
}