UNPKG

@cruncheevos/core

Version:

Parse and generate achievements and leaderboards for RetroAchievements.org

243 lines (242 loc) 8.71 kB
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; } }