diff --git a/src/js/platform/achievement_provider.js b/src/js/platform/achievement_provider.js index 3b60ad95..77c9f1f8 100644 --- a/src/js/platform/achievement_provider.js +++ b/src/js/platform/achievement_provider.js @@ -58,6 +58,17 @@ export const ACHIEVEMENTS = { upgradesTier8: "upgradesTier8", }; +export const HIDDEN_ACHIEVEMENTS = [ + ACHIEVEMENTS.placeBp1000, + ACHIEVEMENTS.darkMode, + ACHIEVEMENTS.irrelevantShape, + ACHIEVEMENTS.logoBefore18, + ACHIEVEMENTS.mapMarkers15, + ACHIEVEMENTS.produceMsLogo, + ACHIEVEMENTS.belt500Tiles, + ACHIEVEMENTS.oldLevel17, +]; + /** @type {keyof typeof THEMES} */ const DARK_MODE = "dark"; @@ -87,6 +98,7 @@ export class AchievementProviderInterface { /** @param {Application} app */ constructor(app) { this.app = app; + this.storage = null; } /** @@ -167,101 +179,14 @@ export class AchievementCollection { /** * @param {function} activate - Resolves when provider activation is complete */ - constructor(activate) { + constructor(activate, deactivate) { this.map = new Map(); this.activate = activate; + this.deactivate = deactivate; - this.add(ACHIEVEMENTS.belt500Tiles, { - isValid: this.isBelt500TilesValid, - signal: "entityAdded", - }); - this.add(ACHIEVEMENTS.blueprint100k, this.createBlueprintOptions(100000)); - this.add(ACHIEVEMENTS.blueprint1m, this.createBlueprintOptions(1000000)); - this.add(ACHIEVEMENTS.completeLvl26, this.createLevelOptions(26)); - this.add(ACHIEVEMENTS.cutShape); - this.add(ACHIEVEMENTS.darkMode, { - isValid: this.isDarkModeValid, - }); - this.add(ACHIEVEMENTS.destroy1000, { - isValid: this.isDestroy1000Valid, - }); - this.add(ACHIEVEMENTS.irrelevantShape, { - isValid: this.isIrrelevantShapeValid, - signal: "shapeDelivered", - }); - this.add(ACHIEVEMENTS.level100, this.createLevelOptions(100)); - this.add(ACHIEVEMENTS.level50, this.createLevelOptions(50)); - this.add(ACHIEVEMENTS.logoBefore18, { - isValid: this.isLogoBefore18Valid, - signal: "itemProduced", - }); - this.add(ACHIEVEMENTS.mam, { - isValid: this.isMamValid, - }); - this.add(ACHIEVEMENTS.mapMarkers15, { - isValid: this.isMapMarkers15Valid, - }); - this.add(ACHIEVEMENTS.noBeltUpgradesUntilBp, { - isValid: this.isNoBeltUpgradesUntilBpValid, - signal: "storyGoalCompleted", - }); - this.add(ACHIEVEMENTS.noInverseRotater, { - init: this.initNoInverseRotater, - isValid: this.isNoInverseRotaterValid, - signal: "storyGoalCompleted", - }); - this.add(ACHIEVEMENTS.oldLevel17, this.createShapeOptions(SHAPE_OLD_LEVEL_17)); - this.add(ACHIEVEMENTS.openWires, { - isValid: this.isOpenWiresValid, - signal: "editModeChanged", - }); - this.add(ACHIEVEMENTS.paintShape); - this.add(ACHIEVEMENTS.place5000Wires, { - isValid: this.isPlace5000WiresValid, - }); - this.add(ACHIEVEMENTS.placeBlueprint, { - isValid: this.isPlaceBlueprintValid, - }); - this.add(ACHIEVEMENTS.placeBp1000, { - isValid: this.isPlaceBp1000Valid, - }); - this.add(ACHIEVEMENTS.play1h, this.createTimeOptions(HOUR_1)); - this.add(ACHIEVEMENTS.play10h, this.createTimeOptions(HOUR_10)); - this.add(ACHIEVEMENTS.play20h, this.createTimeOptions(HOUR_20)); - this.add(ACHIEVEMENTS.produceLogo, this.createShapeOptions(SHAPE_LOGO)); - this.add(ACHIEVEMENTS.produceRocket, this.createShapeOptions(SHAPE_ROCKET)); - this.add(ACHIEVEMENTS.produceMsLogo, this.createShapeOptions(SHAPE_MS_LOGO)); - this.add(ACHIEVEMENTS.rotateShape); - this.add(ACHIEVEMENTS.speedrunBp30, this.createSpeedOptions(12, MINUTE_30)); - this.add(ACHIEVEMENTS.speedrunBp60, this.createSpeedOptions(12, MINUTE_60)); - this.add(ACHIEVEMENTS.speedrunBp120, this.createSpeedOptions(12, MINUTE_120)); - this.add(ACHIEVEMENTS.stack4Layers, { - isValid: this.isStack4LayersValid, - signal: "itemProduced", - }); - this.add(ACHIEVEMENTS.stackShape); - this.add(ACHIEVEMENTS.store100Unique, { - init: this.initStore100Unique, - isValid: this.isStore100UniqueValid, - signal: "shapeDelivered", - }); - this.add(ACHIEVEMENTS.storeShape, { - init: this.initStoreShape, - isValid: this.isStoreShapeValid, - }); - this.add(ACHIEVEMENTS.throughputBp25, this.createRateOptions(SHAPE_BP, 25)); - this.add(ACHIEVEMENTS.throughputBp50, this.createRateOptions(SHAPE_BP, 50)); - this.add(ACHIEVEMENTS.throughputLogo25, this.createRateOptions(SHAPE_LOGO, 25)); - this.add(ACHIEVEMENTS.throughputLogo50, this.createRateOptions(SHAPE_LOGO, 50)); - this.add(ACHIEVEMENTS.throughputRocket10, this.createRateOptions(SHAPE_ROCKET, 10)); - this.add(ACHIEVEMENTS.throughputRocket20, this.createRateOptions(SHAPE_ROCKET, 20)); - this.add(ACHIEVEMENTS.trash1000, { - init: this.initTrash1000, - isValid: this.isTrash1000Valid, - }); - this.add(ACHIEVEMENTS.unlockWires, this.createLevelOptions(20)); - this.add(ACHIEVEMENTS.upgradesTier5, this.createUpgradeOptions(5)); - this.add(ACHIEVEMENTS.upgradesTier8, this.createUpgradeOptions(8)); + for (const key in ACHIEVEMENTS) { + this.add(ACHIEVEMENTS[key], this.getAchievementOptions(ACHIEVEMENTS[key])); + } } /** @param {GameRoot} root */ @@ -271,14 +196,7 @@ export class AchievementCollection { this.root.signals.bulkAchievementCheck.add(this.bulkUnlock, this); for (let [key, achievement] of this.map.entries()) { - if (achievement.signal) { - achievement.receiver = this.unlock.bind(this, key); - this.root.signals[achievement.signal].add(achievement.receiver); - } - - if (achievement.init) { - achievement.init(); - } + this.initializeAchievement(key, achievement); } if (!this.hasDefaultReceivers()) { @@ -342,6 +260,7 @@ export class AchievementCollection { achievement .unlock() .then(() => { + if (this.map.has(key)) this.root.signals.achievementCompleted.dispatch(key, data); this.onActivate(null, key); }) .catch(err => { @@ -349,6 +268,50 @@ export class AchievementCollection { }); } + /** + * @param {string} key - Maps to an Achievement + */ + preUnlock(key) { + if (!this.map.has(key)) { + return; + } + + const achievement = this.map.get(key); + + achievement + .unlock() + .then(() => { + this.onActivate(null, key); + }) + .catch(err => { + this.onActivate(err, key); + }); + } + + /** + * @param {string} key - Maps to an Achievement + */ + lock(key) { + this.add(key, this.getAchievementOptions(key)); + this.initializeAchievement(key, this.map.get(key)); + this.deactivate(key); + } + + /** + * @param {string} key - Maps to an Achievement + * @param {Achievement} achievement - Achievement + */ + initializeAchievement(key, achievement) { + if (achievement.signal) { + achievement.receiver = this.unlock.bind(this, key); + this.root.signals[achievement.signal].add(achievement.receiver); + } + + if (achievement.init) { + achievement.init(); + } + } + /** * Cleans up after achievement activation attempt with the provider. Could * utilize err to retry some number of times if needed. @@ -393,6 +356,103 @@ export class AchievementCollection { return false; } + getAchievementOptions(key) { + const enum_achievement_mappings = { + [ACHIEVEMENTS.belt500Tiles]: { + isValid: this.isBelt500TilesValid, + signal: "entityAdded", + }, + [ACHIEVEMENTS.blueprint100k]: this.createBlueprintOptions(100000), + [ACHIEVEMENTS.blueprint1m]: this.createBlueprintOptions(1000000), + [ACHIEVEMENTS.completeLvl26]: this.createLevelOptions(26), + [ACHIEVEMENTS.cutShape]: {}, + [ACHIEVEMENTS.darkMode]: { + isValid: this.isDarkModeValid, + }, + [ACHIEVEMENTS.destroy1000]: { + isValid: this.isDestroy1000Valid, + }, + [ACHIEVEMENTS.irrelevantShape]: { + isValid: this.isIrrelevantShapeValid, + signal: "shapeDelivered", + }, + [ACHIEVEMENTS.level100]: this.createLevelOptions(100), + [ACHIEVEMENTS.level50]: this.createLevelOptions(50), + [ACHIEVEMENTS.logoBefore18]: { + isValid: this.isLogoBefore18Valid, + signal: "itemProduced", + }, + [ACHIEVEMENTS.mam]: { + isValid: this.isMamValid, + }, + [ACHIEVEMENTS.mapMarkers15]: { + isValid: this.isMapMarkers15Valid, + }, + [ACHIEVEMENTS.noBeltUpgradesUntilBp]: { + isValid: this.isNoBeltUpgradesUntilBpValid, + signal: "storyGoalCompleted", + }, + [ACHIEVEMENTS.noInverseRotater]: { + init: this.initNoInverseRotater, + isValid: this.isNoInverseRotaterValid, + signal: "storyGoalCompleted", + }, + [ACHIEVEMENTS.oldLevel17]: this.createShapeOptions(SHAPE_OLD_LEVEL_17), + [ACHIEVEMENTS.openWires]: { + isValid: this.isOpenWiresValid, + signal: "editModeChanged", + }, + [ACHIEVEMENTS.paintShape]: {}, + [ACHIEVEMENTS.place5000Wires]: { + isValid: this.isPlace5000WiresValid, + }, + [ACHIEVEMENTS.placeBlueprint]: { + isValid: this.isPlaceBlueprintValid, + }, + [ACHIEVEMENTS.placeBp1000]: { + isValid: this.isPlaceBp1000Valid, + }, + [ACHIEVEMENTS.play1h]: this.createTimeOptions(HOUR_1), + [ACHIEVEMENTS.play10h]: this.createTimeOptions(HOUR_10), + [ACHIEVEMENTS.play20h]: this.createTimeOptions(HOUR_20), + [ACHIEVEMENTS.produceLogo]: this.createShapeOptions(SHAPE_LOGO), + [ACHIEVEMENTS.produceRocket]: this.createShapeOptions(SHAPE_ROCKET), + [ACHIEVEMENTS.produceMsLogo]: this.createShapeOptions(SHAPE_MS_LOGO), + [ACHIEVEMENTS.rotateShape]: {}, + [ACHIEVEMENTS.speedrunBp30]: this.createSpeedOptions(12, MINUTE_30), + [ACHIEVEMENTS.speedrunBp60]: this.createSpeedOptions(12, MINUTE_60), + [ACHIEVEMENTS.speedrunBp120]: this.createSpeedOptions(12, MINUTE_120), + [ACHIEVEMENTS.stack4Layers]: { + isValid: this.isStack4LayersValid, + signal: "itemProduced", + }, + [ACHIEVEMENTS.stackShape]: {}, + [ACHIEVEMENTS.store100Unique]: { + init: this.initStore100Unique, + isValid: this.isStore100UniqueValid, + signal: "shapeDelivered", + }, + [ACHIEVEMENTS.storeShape]: { + init: this.initStoreShape, + isValid: this.isStoreShapeValid, + }, + [ACHIEVEMENTS.throughputBp25]: this.createRateOptions(SHAPE_BP, 25), + [ACHIEVEMENTS.throughputBp50]: this.createRateOptions(SHAPE_BP, 50), + [ACHIEVEMENTS.throughputLogo25]: this.createRateOptions(SHAPE_LOGO, 25), + [ACHIEVEMENTS.throughputLogo50]: this.createRateOptions(SHAPE_LOGO, 50), + [ACHIEVEMENTS.throughputRocket10]: this.createRateOptions(SHAPE_ROCKET, 25), + [ACHIEVEMENTS.throughputRocket20]: this.createRateOptions(SHAPE_ROCKET, 50), + [ACHIEVEMENTS.trash1000]: { + init: this.initTrash1000, + isValid: this.isTrash1000Valid, + }, + [ACHIEVEMENTS.unlockWires]: this.createLevelOptions(20), + [ACHIEVEMENTS.upgradesTier5]: this.createUpgradeOptions(5), + [ACHIEVEMENTS.upgradesTier8]: this.createUpgradeOptions(8), + }; + return enum_achievement_mappings[key]; + } + /* * Remaining methods exist to extend Achievement instances within the * collection. diff --git a/src/js/platform/browser/browser_achievement_provider.js b/src/js/platform/browser/browser_achievement_provider.js new file mode 100644 index 00000000..0e9cec76 --- /dev/null +++ b/src/js/platform/browser/browser_achievement_provider.js @@ -0,0 +1,163 @@ +import { ExplainedResult } from "../../core/explained_result"; +import { createLogger } from "../../core/logging"; +import { ReadWriteProxy } from "../../core/read_write_proxy"; +import { ACHIEVEMENTS, AchievementProviderInterface, AchievementCollection } from "../achievement_provider"; + +const logger = createLogger("achievements/browser"); + +export class BrowserAchievementStorage extends ReadWriteProxy { + constructor(app) { + super(app, "app_achievements.bin"); + } + + initialize() { + return this.readAsync().then(() => { + console.log(this.currentData); + return Promise.resolve(); + }); + } + + save() { + return this.writeAsync(); + } + + /** @returns {ExplainedResult} */ + verify(data) { + if (!data.unlocked) { + return ExplainedResult.bad("missing key 'unlocked'"); + } + if (!Array.isArray(data.unlocked)) { + return ExplainedResult.bad("Bad array 'unlocked'"); + } + + for (let i = 0; i < data.unlocked.length; i++) { + const achievement = data.unlocked[i]; + let exists = false; + for (const key in ACHIEVEMENTS) { + if (ACHIEVEMENTS[key] === achievement) { + exists = true; + break; + } + } + } + + return ExplainedResult.good(); + } + + // Should return the default data + getDefaultData() { + return { + version: this.getCurrentVersion(), + unlocked: [], + }; + } + + // Should return the current version as an integer + getCurrentVersion() { + return 0; + } + + // Should migrate the data (Modify in place) + /** @returns {ExplainedResult} */ + migrate(data) { + return ExplainedResult.good(); + } +} + +export class BrowserAchievementProvider extends AchievementProviderInterface { + /** @param {import("../game_analytics").Application} app */ + constructor(app) { + super(app); + + this.initialized = false; + this.collection = new AchievementCollection(this.activate.bind(this), this.deactivate.bind(this)); + this.storage = new BrowserAchievementStorage(app); + if (G_IS_DEV) { + for (let key in ACHIEVEMENTS) { + assert(this.collection.map.has(key), "Key not found in collection: " + key); + } + } + + logger.log("Collection created with", this.collection.map.size, "achievements"); + } + + /** @returns {boolean} */ + hasAchievements() { + return true; + } + + /** + * @param {import("../../core/draw_parameters").GameRoot} root + * @returns {Promise} + */ + onLoad(root) { + this.root = root; + try { + this.collection = new AchievementCollection(this.activate.bind(this), this.deactivate.bind(this)); + this.collection.initialize(root); + + //Unlock already unlocked + for (let i = 0; i < this.storage.currentData.unlocked.length; i++) { + const achievement = this.storage.currentData.unlocked[i]; + this.collection.preUnlock(achievement); + } + + logger.log("Initialized", this.collection.map.size, "relevant achievements"); + return Promise.resolve(); + } catch (err) { + logger.error("Failed to initialize the collection"); + return Promise.reject(err); + } + } + + /** @returns {Promise} */ + initialize() { + return Promise.resolve(); + } + + /** + * @param {string} key + * @returns {Promise} + */ + activate(key) { + return Promise.resolve() + .then(() => { + if (!this.storage.currentData.unlocked.includes(key)) + this.storage.currentData.unlocked.push(key); + + return Promise.resolve(); + }) + .then(() => this.storage.save()) + .then(() => { + logger.log("Achievement activated:", key); + }) + .catch(err => { + logger.error("Failed to activate achievement:", key, err); + throw err; + }); + } + + /** + * @param {string} key + * @returns {Promise} + */ + deactivate(key) { + return Promise.resolve() + .then(() => { + if (this.storage.currentData.unlocked.includes(key)) + this.storage.currentData.unlocked.splice( + this.storage.currentData.unlocked.indexOf(key), + 1 + ); + return Promise.resolve(); + }) + .then(() => this.storage.save()) + .then(() => { + logger.log("Achievement deactivated:", key); + }) + .catch(err => { + logger.error("Failed to deactivate achievement:", key, err); + throw err; + }); + } +} diff --git a/src/js/platform/browser/no_achievement_provider.js b/src/js/platform/browser/no_achievement_provider.js index 8a8a343e..24689615 100644 --- a/src/js/platform/browser/no_achievement_provider.js +++ b/src/js/platform/browser/no_achievement_provider.js @@ -20,4 +20,8 @@ export class NoAchievementProvider extends AchievementProviderInterface { activate() { return Promise.resolve(); } + + deactivate() { + return Promise.resolve(); + } } diff --git a/src/js/platform/browser/wrapper.js b/src/js/platform/browser/wrapper.js index 94d174f3..ac846ba9 100644 --- a/src/js/platform/browser/wrapper.js +++ b/src/js/platform/browser/wrapper.js @@ -6,8 +6,8 @@ import { clamp } from "../../core/utils"; import { CrazygamesAdProvider } from "../ad_providers/crazygames"; import { GamedistributionAdProvider } from "../ad_providers/gamedistribution"; import { NoAdProvider } from "../ad_providers/no_ad_provider"; -import { SteamAchievementProvider } from "../electron/steam_achievement_provider"; import { PlatformWrapperInterface } from "../wrapper"; +import { BrowserAchievementProvider } from "./browser_achievement_provider"; import { NoAchievementProvider } from "./no_achievement_provider"; import { StorageImplBrowser } from "./storage"; import { StorageImplBrowserIndexedDB } from "./storage_indexed_db"; @@ -140,7 +140,7 @@ export class PlatformWrapperImplBrowser extends PlatformWrapperInterface { performRestart() { logger.log("Performing restart"); - window.location.reload(true); + window.location.reload(); } /** @@ -192,8 +192,8 @@ export class PlatformWrapperImplBrowser extends PlatformWrapperInterface { } initializeAchievementProvider() { - if (G_IS_DEV && globalConfig.debug.testAchievements) { - this.app.achievementProvider = new SteamAchievementProvider(this.app); + if (G_IS_DEV) { + this.app.achievementProvider = new BrowserAchievementProvider(this.app); return this.app.achievementProvider.initialize().catch(err => { logger.error("Failed to initialize achievement provider, disabling:", err); diff --git a/src/js/platform/electron/steam_achievement_provider.js b/src/js/platform/electron/steam_achievement_provider.js index 638cdbc5..43fa7507 100644 --- a/src/js/platform/electron/steam_achievement_provider.js +++ b/src/js/platform/electron/steam_achievement_provider.js @@ -62,7 +62,7 @@ export class SteamAchievementProvider extends AchievementProviderInterface { super(app); this.initialized = false; - this.collection = new AchievementCollection(this.activate.bind(this)); + this.collection = new AchievementCollection(this.activate.bind(this), this.deactivate.bind(this)); if (G_IS_DEV) { for (let key in ACHIEVEMENT_IDS) { @@ -86,11 +86,25 @@ export class SteamAchievementProvider extends AchievementProviderInterface { this.root = root; try { - this.collection = new AchievementCollection(this.activate.bind(this)); + this.collection = new AchievementCollection(this.activate.bind(this), this.deactivate.bind(this)); this.collection.initialize(root); - logger.log("Initialized", this.collection.map.size, "relevant achievements"); - return Promise.resolve(); + let promise = Promise.resolve(); + + //Unlock already unlocked + for (const id in ACHIEVEMENT_IDS) { + promise.then(() => + ipcRenderer.invoke("steam:get-achievement", ACHIEVEMENT_IDS[id]).then(is_achieved => { + if (is_achieved) this.collection.preUnlock(id); + return Promise.resolve(); + }) + ); + } + + return promise.then(() => { + logger.log("Initialized", this.collection.map.size, "relevant achievements"); + return Promise.resolve(); + }); } catch (err) { logger.error("Failed to initialize the collection"); return Promise.reject(err); @@ -145,4 +159,27 @@ export class SteamAchievementProvider extends AchievementProviderInterface { throw err; }); } + + /** + * @param {string} key + * @returns {Promise} + */ + deactivate(key) { + let promise; + + if (!this.initialized) { + promise = Promise.resolve(); + } else { + promise = ipcRenderer.invoke("steam:deactivate-achievement", ACHIEVEMENT_IDS[key]); + } + + return promise + .then(() => { + logger.log("Achievement deactivated:", key); + }) + .catch(err => { + logger.error("Failed to deactivate achievement:", key, err); + throw err; + }); + } }