From 10b90a8df2d347df0110fa3cb720067e671f7e1b Mon Sep 17 00:00:00 2001 From: Greg Considine Date: Mon, 8 Mar 2021 19:07:23 -0500 Subject: [PATCH] [WIP] Add achievements. Start savefile migration --- src/js/core/config.js | 2 +- src/js/game/achievement_proxy.js | 66 ++++- src/js/game/achievement_resolver.js | 4 + src/js/game/buildings/trash.js | 23 ++ src/js/game/shape_definition_manager.js | 10 - src/js/game/systems/item_ejector.js | 3 - src/js/platform/achievement_provider.js | 246 ++++++++++-------- .../electron/steam_achievement_provider.js | 10 + src/js/savegame/schemas/1008.js | 26 ++ src/js/savegame/schemas/1008.json | 5 + 10 files changed, 270 insertions(+), 125 deletions(-) create mode 100644 src/js/game/achievement_resolver.js create mode 100644 src/js/savegame/schemas/1008.js create mode 100644 src/js/savegame/schemas/1008.json diff --git a/src/js/core/config.js b/src/js/core/config.js index c9cb75bf..d5dc7089 100644 --- a/src/js/core/config.js +++ b/src/js/core/config.js @@ -41,7 +41,7 @@ export const globalConfig = { shapesSharpness: 1.4, // Achievements - achievementSliceDuration: 30, // Seconds + achievementSliceDuration: 10, // Seconds // Production analytics statisticsGraphDpi: 2.5, diff --git a/src/js/game/achievement_proxy.js b/src/js/game/achievement_proxy.js index c38fdf47..4aa12ac5 100644 --- a/src/js/game/achievement_proxy.js +++ b/src/js/game/achievement_proxy.js @@ -5,21 +5,42 @@ import { GameRoot } from "./root"; import { globalConfig } from "../core/config"; import { createLogger } from "../core/logging"; import { ACHIEVEMENTS } from "../platform/achievement_provider"; +import { BasicSerializableObject } from "../savegame/serialization"; +//import { typeAchievementCollection } from "./achievement_resolver"; const logger = createLogger("achievement_proxy"); -export class AchievementProxy { +export class AchievementProxy extends BasicSerializableObject { + static getId() { + return "AchievementProxy"; + } + + static getSchema() { + return { +// collection: typeAchievementCollection + }; + } + + deserialize(data, root) { + + } + /** @param {GameRoot} root */ constructor(root) { + super(); + this.root = root; this.provider = this.root.app.achievementProvider; - this.lastSlice = 0; this.disabled = true; if (!this.provider.hasAchievements()) { return; } + this.sliceTime = 0; + this.sliceIteration = 0; + this.sliceIterationLimit = 2; + this.root.signals.postLoadHook.add(this.onLoad, this); } @@ -37,14 +58,41 @@ export class AchievementProxy { }); } + // Have certain checks every 30 seconds, 10 seconds, etc. + // Re-check relevance every so often + // Consider disabling checks if no longer relevant startSlice() { - this.lastSlice = this.root.time.now(); + this.sliceTime = this.root.time.now(); - this.root.signals.bulkAchievementCheck.dispatch( - ACHIEVEMENTS.play1h, this.lastSlice, - ACHIEVEMENTS.play10h, this.lastSlice, - ACHIEVEMENTS.play20h, this.lastSlice - ); + // Every slice + this.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.storeShape, this.sliceTime); + + // Every other slice + if (this.sliceIteration % 2 === 0) { + this.root.signals.bulkAchievementCheck.dispatch( + ACHIEVEMENTS.throughputBp25, this.sliceTime, + ACHIEVEMENTS.throughputBp50, this.sliceTime, + ACHIEVEMENTS.throughputLogo25, this.sliceTime, + ACHIEVEMENTS.throughputLogo50, this.sliceTime, + ACHIEVEMENTS.throughputRocket10, this.sliceTime, + ACHIEVEMENTS.throughputRocket20, this.sliceTime + ); + } + + // Every 3rd slice + if (this.sliceIteration % 3 === 0) { + this.root.signals.bulkAchievementCheck.dispatch( + ACHIEVEMENTS.play1h, this.sliceTime, + ACHIEVEMENTS.play10h, this.sliceTime, + ACHIEVEMENTS.play20h, this.sliceTime + ); + } + + if (this.sliceIteration === this.sliceIterationLimit) { + this.sliceIteration = 0; + } else { + this.sliceIteration++; + } } update() { @@ -52,7 +100,7 @@ export class AchievementProxy { return; } - if (this.root.time.now() - this.lastSlice > globalConfig.achievementSliceDuration) { + if (this.root.time.now() - this.sliceTime > globalConfig.achievementSliceDuration) { this.startSlice(); } } diff --git a/src/js/game/achievement_resolver.js b/src/js/game/achievement_resolver.js new file mode 100644 index 00000000..f24717b6 --- /dev/null +++ b/src/js/game/achievement_resolver.js @@ -0,0 +1,4 @@ +export function achievementResolver(root, data) { + +} + diff --git a/src/js/game/buildings/trash.js b/src/js/game/buildings/trash.js index 43108b9e..c8f11f9b 100644 --- a/src/js/game/buildings/trash.js +++ b/src/js/game/buildings/trash.js @@ -1,5 +1,6 @@ import { generateMatrixRotations } from "../../core/utils"; import { enumDirection, Vector } from "../../core/vector"; +import { ACHIEVEMENTS } from "../../platform/achievement_provider"; import { ItemAcceptorComponent } from "../components/item_acceptor"; import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor"; import { Entity } from "../entity"; @@ -37,6 +38,25 @@ export class MetaTrashBuilding extends MetaBuilding { return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_cutter_and_trash); } + addAchievementReceiver(entity) { + if (!entity.root) { + return; + } + + const itemProcessor = entity.components.ItemProcessor + const tryTakeItem = itemProcessor.tryTakeItem.bind(itemProcessor); + + itemProcessor.tryTakeItem = () => { + const taken = tryTakeItem(...arguments); + + if (taken) { + entity.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.trash1000, 1); + } + + return taken; + } + } + /** * Creates the entity at the given location * @param {Entity} entity @@ -57,11 +77,14 @@ export class MetaTrashBuilding extends MetaBuilding { ], }) ); + entity.addComponent( new ItemProcessorComponent({ inputsPerCharge: 1, processorType: enumItemProcessorTypes.trash, }) ); + + this.addAchievementReceiver(entity); } } diff --git a/src/js/game/shape_definition_manager.js b/src/js/game/shape_definition_manager.js index 13bc614a..92fc9538 100644 --- a/src/js/game/shape_definition_manager.js +++ b/src/js/game/shape_definition_manager.js @@ -251,16 +251,6 @@ export class ShapeDefinitionManager extends BasicSerializableObject { return this.shapeKeyToDefinition[id]; } this.shapeKeyToDefinition[id] = definition; - - this.root.signals.bulkAchievementCheck.dispatch( - ACHIEVEMENTS.logoBefore18, definition, - ACHIEVEMENTS.oldLevel17, definition, - ACHIEVEMENTS.produceLogo, definition, - ACHIEVEMENTS.produceMsLogo, definition, - ACHIEVEMENTS.produceRocket, definition, - ACHIEVEMENTS.stack4Layers, definition - ); - // logger.log("Registered shape with key (2)", id); return definition; } diff --git a/src/js/game/systems/item_ejector.js b/src/js/game/systems/item_ejector.js index acd87e52..1daaad6b 100644 --- a/src/js/game/systems/item_ejector.js +++ b/src/js/game/systems/item_ejector.js @@ -275,9 +275,6 @@ export class ItemEjectorSystem extends GameSystemWithFilter { // It's a storage if (storageComp.canAcceptItem(item)) { storageComp.takeItem(item); - - this.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.storeShape, storageComp); - return true; } diff --git a/src/js/platform/achievement_provider.js b/src/js/platform/achievement_provider.js index 6345a61b..fc96e916 100644 --- a/src/js/platform/achievement_provider.js +++ b/src/js/platform/achievement_provider.js @@ -1,11 +1,14 @@ /* typehints:start */ import { Application } from "../application"; +import { BaseItem } from "./base_item"; import { StorageComponent } from "../game/components/storage"; import { Entity } from "../game/entity"; import { GameRoot } from "../game/root"; import { ShapeDefinition } from "../game/shape_definition"; /* typehints:end */ +import { enumAnalyticsDataSource } from "../game/production_analytics"; + export const ACHIEVEMENTS = { belt500Tiles: "belt500Tiles", blueprint100k: "blueprint100k", @@ -32,25 +35,41 @@ export const ACHIEVEMENTS = { produceMsLogo: "produceMsLogo", produceRocket: "produceRocket", rotateShape: "rotateShape", + speedrunBp30: "speedrunBp30", + speedrunBp60: "speedrunBp60", + speedrunBp120: "speedrunBp120", stack4Layers: "stack4Layers", stackShape: "stackShape", store100Unique: "store100Unique", storeShape: "storeShape", + throughputBp25: "throughputBp25", + throughputBp50: "throughputBp50", + throughputLogo25: "throughputLogo25", + throughputLogo50: "throughputLogo50", + throughputRocket10: "throughputRocket10", + throughputRocket20: "throughputRocket20", + trash1000: "trash1000", unlockWires: "unlockWires", upgradesTier5: "upgradesTier5", upgradesTier8: "upgradesTier8", }; const DARK_MODE = "dark"; -const WIRE_LAYER = "wires"; const HOUR_1 = 3600; // Seconds const HOUR_10 = HOUR_1 * 10; const HOUR_20 = HOUR_1 * 20; -const SHAPE_BLUEPRINT = "CbCbCbRb:CwCwCwCw"; +const ITEM_SHAPE = "shape"; +const MINUTE_30 = 1800; // Seconds +const MINUTE_60 = MINUTE_30 * 2; +const MINUTE_120 = MINUTE_30 * 4; +const PRODUCED = "produced"; +const RATE_SLICE_COUNT = 10; +const SHAPE_BP = "CbCbCbRb:CwCwCwCw"; const SHAPE_LOGO = "RuCw--Cw:----Ru--"; const SHAPE_MS_LOGO = "RgRyRbRr"; const SHAPE_ROCKET = "CbCuCbCu:Sr------:--CrSrCr:CwCwCwCw"; const SHAPE_OLD_LEVEL_17 = "WrRgWrRg:CwCrCwCr:SgSgSgSg"; +const WIRE_LAYER = "wires"; export class AchievementProviderInterface { /** @param {Application} app */ @@ -167,6 +186,7 @@ export class AchievementCollection { this.createAndSet(ACHIEVEMENTS.logoBefore18, { isRelevant: this.isLogoBefore18Relevant, isValid: this.isLogoBefore18Valid, + signal: "itemProduced" }); this.createAndSet(ACHIEVEMENTS.mapMarkers15, { isRelevant: this.isMapMarkers15Relevant, @@ -194,8 +214,12 @@ export class AchievementCollection { this.createAndSet(ACHIEVEMENTS.produceRocket, this.createShapeOptions(SHAPE_ROCKET)); this.createAndSet(ACHIEVEMENTS.produceMsLogo, this.createShapeOptions(SHAPE_MS_LOGO)); this.createAndSet(ACHIEVEMENTS.rotateShape); + this.createAndSet(ACHIEVEMENTS.speedrunBp30, this.createSpeedOptions(12, MINUTE_30)); + this.createAndSet(ACHIEVEMENTS.speedrunBp60, this.createSpeedOptions(12, MINUTE_60)); + this.createAndSet(ACHIEVEMENTS.speedrunBp120, this.createSpeedOptions(12, MINUTE_120)); this.createAndSet(ACHIEVEMENTS.stack4Layers, { isValid: this.isStack4LayersValid, + signal: "itemProduced", }); this.createAndSet(ACHIEVEMENTS.stackShape); this.createAndSet(ACHIEVEMENTS.store100Unique, { @@ -205,7 +229,16 @@ export class AchievementCollection { }); this.createAndSet(ACHIEVEMENTS.storeShape, { isValid: this.isStoreShapeValid, - signal: "entityGotNewComponent", + }); + this.createAndSet(ACHIEVEMENTS.throughputBp25, this.createRateOptions(SHAPE_BP, 25)); + this.createAndSet(ACHIEVEMENTS.throughputBp50, this.createRateOptions(SHAPE_BP, 50)); + this.createAndSet(ACHIEVEMENTS.throughputLogo25, this.createRateOptions(SHAPE_LOGO, 25)); + this.createAndSet(ACHIEVEMENTS.throughputLogo50, this.createRateOptions(SHAPE_LOGO, 50)); + this.createAndSet(ACHIEVEMENTS.throughputRocket10, this.createRateOptions(SHAPE_ROCKET, 25)); + this.createAndSet(ACHIEVEMENTS.throughputRocket20, this.createRateOptions(SHAPE_ROCKET, 50)); + this.createAndSet(ACHIEVEMENTS.trash1000, { + init: this.initTrash1000, + isValid: this.isTrash1000Valid, }); this.createAndSet(ACHIEVEMENTS.unlockWires, this.createLevelOptions(20)); this.createAndSet(ACHIEVEMENTS.upgradesTier5, this.createUpgradeOptions(5)); @@ -219,6 +252,10 @@ export class AchievementCollection { this.root.signals.bulkAchievementCheck.add(this.bulkUnlock, this); for (let [key, achievement] of this.map.entries()) { + if (achievement.init) { + achievement.init(); + } + if (!achievement.isRelevant()) { this.remove(key); continue; @@ -251,6 +288,10 @@ export class AchievementCollection { achievement.activate = this.activate; + if (options.init) { + achievement.init = options.init.bind(this, achievement); + } + if (options.isValid) { achievement.isValid = options.isValid.bind(this); } @@ -274,16 +315,16 @@ export class AchievementCollection { /** * @param {string} key - Maps to an Achievement - * @param {*[]} [arguments] - Additional arguments received from signal dispatches + * @param {?*} data - Data received from signal dispatches for validation */ - unlock(key) { + unlock(key, data) { if (!this.map.has(key)) { return; } const achievement = this.map.get(key); - if (!achievement.isValid(...arguments)) { + if (!achievement.isValid(data, achievement.state)) { return; } @@ -347,17 +388,46 @@ export class AchievementCollection { return true; } + /** + * @param {BaseItem} item + * @param {string} shape + * @returns {boolean} + */ + isShape(item, shape) { + return item.getItemType() === ITEM_SHAPE && item.definition.getHash() === shape; + } + createLevelOptions(level) { return { isRelevant: () => this.root.hubGoals.level < level, - isValid: (key, currentLevel) => currentLevel === level, + isValid: (currentLevel) => currentLevel === level, signal: "storyGoalCompleted", }; } + createRateOptions(shape, rate) { + return { + isValid: () => { + return this.root.productionAnalytics.getCurrentShapeRate( + enumAnalyticsDataSource.produced, + this.root.shapeDefinitionMgr.getShapeFromShortKey(shape) + ) >= rate; + } + }; + } + createShapeOptions(shape) { return { - isValid: (key, definition) => definition.cachedHash === shape, + isValid: (item) => this.isShape(item, shape), + signal: "itemProduced", + }; + } + + createSpeedOptions(level, time) { + return { + isRelevant: () => this.root.hubGoals.level <= level && this.root.time.now() < time, + isValid: (currentLevel) => currentLevel === level && this.root.time.now() < time, + signal: "storyGoalCompleted", }; } @@ -376,62 +446,39 @@ export class AchievementCollection { }; } - /** - * @param {string} key - * @param {Entity} entity - * @returns {boolean} - */ - isBelt500TilesValid(key, entity) { + /** @param {Entity} entity @returns {boolean} */ + isBelt500TilesValid(entity) { return entity.components.Belt && entity.components.Belt.assignedPath.totalLength >= 500; } - /** - * @param {string} key - * @param {ShapeDefinition} definition - * @returns {boolean} - */ - isBlueprint100kValid(key, definition) { + /** @param {ShapeDefinition} definition @returns {boolean} */ + isBlueprint100kValid(definition) { return ( - definition.cachedHash === SHAPE_BLUEPRINT && - this.root.hubGoals.storedShapes[SHAPE_BLUEPRINT] >= 100000 + definition.cachedHash === SHAPE_BP && + this.root.hubGoals.storedShapes[SHAPE_BP] >= 100000 ); } - /** - * @param {string} key - * @param {ShapeDefinition} definition - * @returns {boolean} - */ - isBlueprint1mValid(key, definition) { + /** @param {ShapeDefinition} definition @returns {boolean} */ + isBlueprint1mValid(definition) { return ( - definition.cachedHash === SHAPE_BLUEPRINT && - this.root.hubGoals.storedShapes[SHAPE_BLUEPRINT] >= 1000000 + definition.cachedHash === SHAPE_BP && + this.root.hubGoals.storedShapes[SHAPE_BP] >= 1000000 ); } - /** - * @param {string} key - * @returns {boolean} - */ - isDarkModeValid(key) { + /** @returns {boolean} */ + isDarkModeValid() { return this.root.app.settings.currentData.settings.theme === DARK_MODE; } - /** - * @param {string} key - * @param {number} count - The count of selected entities destroyed - * @returns {boolean} - */ - isDestroy1000Valid(key, count) { + /** @param {number} count @returns {boolean} */ + isDestroy1000Valid(count) { return count >= 1000; } - /** - * @param {string} key - * @param {ShapeDefinition} definition - * @returns {boolean} - */ - isIrrelevantShapeValid(key, definition) { + /** @param {ShapeDefinition} definition @returns {boolean} */ + isIrrelevantShapeValid(definition) { if (definition.cachedHash === this.root.hubGoals.currentGoal.definition.cachedHash) { return false; } @@ -456,13 +503,9 @@ export class AchievementCollection { return this.root.hubGoals.level < 18; } - /** - * @param {string} key - * @param {ShapeDefinition} definition - * @returns {boolean} - */ - isLogoBefore18Valid(key, definition) { - return this.root.hubGoals.level < 18 && definition.cachedHash === SHAPE_LOGO; + /** @param {BaseItem} item @returns {boolean} */ + isLogoBefore18Valid(item) { + return this.root.hubGoals.level < 18 && this.isShape(item, SHAPE_LOGO); } /** @returns {boolean} */ @@ -470,30 +513,18 @@ export class AchievementCollection { return this.root.hud.parts.waypoints.waypoints.length < 16; // 16 - HUB } - /** - * @param {string} key - * @param {number} count - Count of map markers excluding HUB - * @returns {boolean} - */ - isMapMarkers15Valid(key, count) { + /** @param {number} count @returns {boolean} */ + isMapMarkers15Valid(count) { return count === 15; } - /** - * @param {string} key - * @param {string} currentLayer - * @returns {boolean} - */ - isOpenWiresValid(key, currentLayer) { + /** @param {string} currentLayer @returns {boolean} */ + isOpenWiresValid(currentLayer) { return currentLayer === WIRE_LAYER; } - /** - * @param {string} key - * @param {Entity} entity - * @returns {boolean} - */ - isPlace5000WiresValid(key, entity) { + /** @param {Entity} entity @returns {boolean} */ + isPlace5000WiresValid(entity) { return ( entity.components.Wire && entity.registered && @@ -501,31 +532,19 @@ export class AchievementCollection { ); } - /** - * @param {string} key - * @param {number} count - * @returns {boolean} - */ - isPlaceBlueprintValid(key, count) { + /** @param {number} count @returns {boolean} */ + isPlaceBlueprintValid(count) { return count != 0; } - /** - * @param {string} key - * @param {number} count - * @returns {boolean} - */ - isPlaceBp1000Valid(key, count) { + /** @param {number} count @returns {boolean} */ + isPlaceBp1000Valid(count) { return count >= 1000; } - /** - * @param {string} key - * @param {ShapeDefinition} definition - * @returns {boolean} - */ - isStack4LayersValid(key, definition) { - return definition.layers.length === 4; + /** @param {string} key @param {BaseItem} item @returns {boolean} */ + isStack4LayersValid(item) { + return item.getItemType() === ITEM_SHAPE && item.definition.layers.length === 4; } /** @returns {boolean} */ @@ -533,20 +552,43 @@ export class AchievementCollection { return Object.keys(this.root.hubGoals.storedShapes).length < 100; } - /** - * @param {string} key - * @returns {boolean} - */ - isStore100UniqueValid(key) { + /** @returns {boolean} */ + isStore100UniqueValid() { return Object.keys(this.root.hubGoals.storedShapes).length === 100; } + /** @param {StorageComponent} storage @returns {boolean} */ + isStoreShapeValid() { + const entities = this.root.systemMgr.systems.storage.allEntities; + + if (entities.length === 0) { + return false; + } + + for (var i = 0; i < entities.length; i++) { + if (entities[i].components.Storage.storedCount > 0) { + return true; + } + } + + return false; + } + + initTrash1000(achievement) { + // get state from root + console.log(this.root.savegame.currentData.dump); + + achievement.state = achievement.state || { + count: 0 + }; + } /** - * @param {string} key - * @param {StorageComponent} storage - * @returns {boolean} - */ - isStoreShapeValid(key, storage) { - return storage.storedCount >= 1; + * @params {number} count + * @params {object} state - The achievement's current state + * @returns {boolean} */ + isTrash1000Valid(count, state) { + state.count += count; + + return state.count >= 1000; } } diff --git a/src/js/platform/electron/steam_achievement_provider.js b/src/js/platform/electron/steam_achievement_provider.js index f797c903..b39fef79 100644 --- a/src/js/platform/electron/steam_achievement_provider.js +++ b/src/js/platform/electron/steam_achievement_provider.js @@ -35,10 +35,20 @@ const ACHIEVEMENT_IDS = { [ACHIEVEMENTS.produceMsLogo]: "produce_ms_logo", [ACHIEVEMENTS.produceRocket]: "produce_rocket", [ACHIEVEMENTS.rotateShape]: "rotate_shape", + [ACHIEVEMENTS.speedrunBp30]: "speedrun_bp_30", + [ACHIEVEMENTS.speedrunBp60]: "speedrun_bp_60", + [ACHIEVEMENTS.speedrunBp120]: "speedrun_bp_120", [ACHIEVEMENTS.stack4Layers]: "stack_4_layers", [ACHIEVEMENTS.stackShape]: "stack_shape", [ACHIEVEMENTS.store100Unique]: "store_100_unique", [ACHIEVEMENTS.storeShape]: "store_shape", + [ACHIEVEMENTS.throughputBp25]: "throughput_bp_25", + [ACHIEVEMENTS.throughputBp50]: "throughput_bp_50", + [ACHIEVEMENTS.throughputLogo25]: "throughput_logo_25", + [ACHIEVEMENTS.throughputLogo50]: "throughput_logo_50", + [ACHIEVEMENTS.throughputRocket10]: "throughput_rocket_10", + [ACHIEVEMENTS.throughputRocket20]: "throughput_rocket_20", + [ACHIEVEMENTS.trash1000]: "trash_1000", [ACHIEVEMENTS.unlockWires]: "unlock_wires", [ACHIEVEMENTS.upgradesTier5]: "upgrades_tier_5", [ACHIEVEMENTS.upgradesTier8]: "upgrades_tier_8", diff --git a/src/js/savegame/schemas/1008.js b/src/js/savegame/schemas/1008.js new file mode 100644 index 00000000..2b99ce21 --- /dev/null +++ b/src/js/savegame/schemas/1008.js @@ -0,0 +1,26 @@ +import { createLogger } from "../../core/logging.js"; +import { SavegameInterface_V1007 } from "./1007.js"; + +const schema = require("./1008.json"); +const logger = createLogger("savegame_interface/1008"); + +export class SavegameInterface_V1008 extends SavegameInterface_V1007 { + getVersion() { + return 1008; + } + + getSchemaUncached() { + return schema; + } + + /** + * @param {import("../savegame_typedefs.js").SavegameData} data + */ + static migrate1007to1008(data) { + logger.log("Migrating 1007 to 1008"); + const dump = data.dump; + if (!dump) { + return true; + } + } +} diff --git a/src/js/savegame/schemas/1008.json b/src/js/savegame/schemas/1008.json new file mode 100644 index 00000000..6682f615 --- /dev/null +++ b/src/js/savegame/schemas/1008.json @@ -0,0 +1,5 @@ +{ + "type": "object", + "required": [], + "additionalProperties": true +}