diff --git a/gulp/html.js b/gulp/html.js index b8af0e53..d9889fe1 100644 --- a/gulp/html.js +++ b/gulp/html.js @@ -13,15 +13,7 @@ function gulptasksHTML($, gulp, buildFolder, browserSync) { const commitHash = buildUtils.getRevision(); async function buildHtml( apiUrl, - { - analytics = false, - standalone = false, - app = false, - integrity = true, - enableCachebust = true, - gameAnalyticsKey = null, - gameAnalyticsSecret = null, - } + { analytics = false, standalone = false, app = false, integrity = true, enableCachebust = true } ) { function cachebust(url) { if (enableCachebust) { @@ -87,21 +79,6 @@ function gulptasksHTML($, gulp, buildFolder, browserSync) { // document.head.appendChild(logrocketInit); } - if (gameAnalyticsKey && gameAnalyticsSecret) { - const gaLoader = document.createElement("script"); - gaLoader.textContent = ` - window.GameAnalytics=window.GameAnalytics||function(){(GameAnalytics.q=GameAnalytics.q||[]).push(arguments)}; - window.ga_comKey = "${gameAnalyticsKey}"; - window.ga_comToken = "${gameAnalyticsSecret}"; - `; - document.head.appendChild(gaLoader); - - const gaScript = document.createElement("script"); - gaScript.src = "https://download.gameanalytics.com/js/GameAnalytics-4.0.10.min.js"; - gaScript.setAttribute("async", ""); - document.head.appendChild(gaScript); - } - if (app) { // Append cordova link const cdv = document.createElement("script"); @@ -305,27 +282,18 @@ function gulptasksHTML($, gulp, buildFolder, browserSync) { analytics: false, integrity: false, enableCachebust: false, - gameAnalyticsKey: "c8d77921633d5c32a7134e5d5cfcdf12", - // Not an actual "secret" since its built into the JS code - gameAnalyticsSecret: "6d23b40a70199bff0e7a7d8a073543772cf07097", }); }); gulp.task("html.staging", () => { return buildHtml("https://api-staging.shapez.io", { analytics: true, - gameAnalyticsKey: "903fa0dd2d2e23b07e66ea96ddc4c10c", - // Not an actual "secret" since its built into the JS code - gameAnalyticsSecret: "9417fc391d7142b9d73a3861ba6046cafa9df6cb", }); }); gulp.task("html.prod", () => { return buildHtml("https://api.shapez.io", { analytics: true, - gameAnalyticsKey: "16c7f9d352e40c92f6a750fc1a4f0443", - // Not an actual "secret" since its built into the JS code - gameAnalyticsSecret: "4202d7adf154c325ff91731e8be6912e6c0d10e5", }); }); diff --git a/src/js/application.js b/src/js/application.js index 75d47ab0..0690ec55 100644 --- a/src/js/application.js +++ b/src/js/application.js @@ -27,7 +27,7 @@ import { AnalyticsInterface } from "./platform/analytics"; import { GoogleAnalyticsImpl } from "./platform/browser/google_analytics"; import { Loader } from "./core/loader"; import { GameAnalyticsInterface } from "./platform/game_analytics"; -import { GameAnalyticsDotCom } from "./platform/browser/game_analytics"; +import { ShapezGameAnalytics } from "./platform/browser/game_analytics"; const logger = createLogger("application"); @@ -121,7 +121,7 @@ export class Application { this.sound = new SoundImplBrowser(this); this.platformWrapper = new PlatformWrapperImplBrowser(this); this.analytics = new GoogleAnalyticsImpl(this); - this.gameAnalytics = new GameAnalyticsDotCom(this); + this.gameAnalytics = new ShapezGameAnalytics(this); } /** diff --git a/src/js/core/config.js b/src/js/core/config.js index 175ca319..5fe775b4 100644 --- a/src/js/core/config.js +++ b/src/js/core/config.js @@ -66,7 +66,7 @@ export const globalConfig = { debug: { /* dev:start */ - fastGameEnter: false, + fastGameEnter: true, noArtificialDelays: true, disableSavegameWrite: false, showEntityBounds: false, @@ -90,6 +90,9 @@ export const globalConfig = { // Savegame salt sgSalt: "}95Q3%8/.837Lqym_BJx%q7)pAHJbF", + + // Analytics key + analyticsApiKey: "baf6a50f0cc7dfdec5a0e21c88a1c69a4b34bc4a", }, }; diff --git a/src/js/platform/browser/game_analytics.js b/src/js/platform/browser/game_analytics.js index 1c06a602..bb814266 100644 --- a/src/js/platform/browser/game_analytics.js +++ b/src/js/platform/browser/game_analytics.js @@ -1,39 +1,194 @@ import { GameAnalyticsInterface } from "../game_analytics"; import { createLogger } from "../../core/logging"; import { ShapeDefinition } from "../../game/shape_definition"; -import { gameCreationAction } from "../../states/ingame"; +import { Savegame } from "../../savegame/savegame"; +import { FILE_NOT_FOUND } from "../storage"; +import { globalConfig } from "../../core/config"; +import { InGameState } from "../../states/ingame"; +import { GameRoot } from "../../game/root"; +import { StaticMapEntityComponent } from "../../game/components/static_map_entity"; -const logger = createLogger("ga_com"); +const logger = createLogger("game_analytics"); +const analyticsUrl = G_IS_DEV ? "http://localhost:8001" : "https://analytics.shapez.io"; +const analyticsLocalFile = "analytics_token.bin"; -export class GameAnalyticsDotCom extends GameAnalyticsInterface { +export class ShapezGameAnalytics extends GameAnalyticsInterface { /** * @returns {Promise} */ initialize() { - try { - const ga = window.gameanalytics.GameAnalytics; - ga.configureBuild(G_APP_ENVIRONMENT + "@" + G_BUILD_VERSION + "@" + G_BUILD_COMMIT_HASH); + this.syncKey = null; - ga.setEnabledInfoLog(G_IS_DEV); - ga.setEnabledVerboseLog(G_IS_DEV); + setInterval(() => this.sendTimePoints(), 30 * 1000); - // @ts-ignore - ga.initialize(window.ga_comKey, window.ga_comToken); + // Retrieve sync key from player + return this.app.storage.readFileAsync(analyticsLocalFile).then( + syncKey => { + this.syncKey = syncKey; + logger.log("Player sync key read:", this.syncKey); + }, + error => { + // File was not found, retrieve new key + if (error === FILE_NOT_FOUND) { + logger.log("Retrieving new player key"); - // start new session - ga.startSession(); - } catch (ex) { - logger.warn("ga_com init error:", ex); + // Perform call to get a new key from the API + this.sendToApi("/v1/register", { + environment: G_APP_ENVIRONMENT, + }) + .then(res => { + // Try to read and parse the key from the api + if (res.key && typeof res.key === "string" && res.key.length === 40) { + this.syncKey = res.key; + logger.log("Key retrieved:", this.syncKey); + this.app.storage.writeFileAsync(analyticsLocalFile, res.key); + } else { + throw new Error("Bad response from analytics server: " + res); + } + }) + .catch(err => { + logger.error("Failed to register on analytics api:", err); + }); + } else { + logger.error("Failed to read ga key:", error); + } + return; + } + ); + } + + /** + * Sends a request to the api + * @param {string} endpoint Endpoint without base url + * @param {object} data payload + * @returns {Promise} + */ + sendToApi(endpoint, data) { + return Promise.race([ + new Promise((resolve, reject) => { + setTimeout(() => reject("Request to " + endpoint + " timed out"), 20000); + }), + fetch(analyticsUrl + endpoint, { + method: "POST", + mode: "cors", + cache: "no-cache", + referrer: "no-referrer", + credentials: "omit", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "x-api-key": globalConfig.info.analyticsApiKey, + }, + body: JSON.stringify(data), + }) + .then(res => { + if (!res.ok || res.status !== 200) { + throw new Error("Fetch error: Bad status " + res.status); + } + return res; + }) + .then(res => res.json()), + ]); + } + + /** + * Sends a game event to the analytics + * @param {string} category + * @param {string} value + */ + sendGameEvent(category, value) { + if (!this.syncKey) { + logger.warn("Can not send event due to missing sync key"); + return; } - return Promise.resolve(); + + const gameState = this.app.stateMgr.currentState; + if (!(gameState instanceof InGameState)) { + logger.warn("Trying to send analytics event outside of ingame state"); + return; + } + + const savegame = gameState.savegame; + if (!savegame) { + logger.warn("Ingame state has empty savegame"); + return; + } + + const savegameId = savegame.internalId; + const root = gameState.core.root; + if (!root) { + logger.warn("Root is not initialized"); + return; + } + + logger.log("Sending event", category, value); + + this.sendToApi("/v1/game-event", { + playerKey: this.syncKey, + gameKey: savegameId, + ingameTime: root.time.now(), + category, + value, + gameDump: this.generateGameDump(root), + }); + } + + sendTimePoints() { + const gameState = this.app.stateMgr.currentState; + if (gameState instanceof InGameState) { + logger.log("Syncing analytics"); + this.sendGameEvent("sync", ""); + } + } + + /** + * Generates a game dump + * @param {GameRoot} root + */ + generateGameDump(root) { + let staticEntities = []; + + const entities = root.entityMgr.getAllWithComponent(StaticMapEntityComponent); + + for (let i = 0; i < entities.length; ++i) { + const entity = entities[i]; + const staticComp = entity.components.StaticMapEntity; + const payload = {}; + payload.origin = staticComp.origin; + payload.tileSize = staticComp.tileSize; + payload.rotation = staticComp.rotation; + + if (entity.components.Belt) { + payload.type = "belt"; + } else if (entity.components.UndergroundBelt) { + payload.type = "tunnel"; + } else if (entity.components.ItemProcessor) { + payload.type = entity.components.ItemProcessor.type; + } else if (entity.components.Miner) { + payload.type = "extractor"; + } else { + logger.warn("Unkown entity type", entity); + } + staticEntities.push(payload); + } + + return { + storedShapes: root.hubGoals.storedShapes, + gainedRewards: root.hubGoals.gainedRewards, + upgradeLevels: root.hubGoals.upgradeLevels, + staticEntities, + }; } /** * @param {ShapeDefinition} definition */ - handleShapeDelivered(definition) { - const hash = definition.getHash(); - logger.log("Deliver:", hash); + handleShapeDelivered(definition) {} + + /** + */ + handleGameStarted() { + this.sendGameEvent("game_start", ""); } /** @@ -42,13 +197,7 @@ export class GameAnalyticsDotCom extends GameAnalyticsInterface { */ handleLevelCompleted(level) { logger.log("Complete level", level); - try { - const gaD = window.gameanalytics; - const ga = gaD.GameAnalytics; - ga.addProgressionEvent(gaD.EGAProgressionStatus.Complete, "story", "" + level); - } catch (ex) { - logger.error("ga_com lvl complete error:", ex); - } + this.sendGameEvent("level_complete", "" + level); } /** @@ -58,12 +207,6 @@ export class GameAnalyticsDotCom extends GameAnalyticsInterface { */ handleUpgradeUnlocked(id, level) { logger.log("Unlock upgrade", id, level); - try { - const gaD = window.gameanalytics; - const ga = gaD.GameAnalytics; - ga.addProgressionEvent(gaD.EGAProgressionStatus.Complete, "upgrade", id, "" + level); - } catch (ex) { - logger.error("ga_com upgrade unlock error:", ex); - } + this.sendGameEvent("upgrade_unlock", id + "@" + level); } } diff --git a/src/js/platform/game_analytics.js b/src/js/platform/game_analytics.js index 5b70e565..c3e2fa64 100644 --- a/src/js/platform/game_analytics.js +++ b/src/js/platform/game_analytics.js @@ -1,6 +1,7 @@ /* typehints:start */ import { Application } from "../application"; import { ShapeDefinition } from "../game/shape_definition"; +import { Savegame } from "../savegame/savegame"; /* typehints:end */ export class GameAnalyticsInterface { @@ -18,6 +19,11 @@ export class GameAnalyticsInterface { return Promise.reject(); } + /** + * Handles a new game which was started + */ + handleGameStarted() {} + /** * @param {ShapeDefinition} definition */ diff --git a/src/js/savegame/savegame_manager.js b/src/js/savegame/savegame_manager.js index 69a275bb..60f74989 100644 --- a/src/js/savegame/savegame_manager.js +++ b/src/js/savegame/savegame_manager.js @@ -186,14 +186,9 @@ export class SavegameManager extends ReadWriteProxy { * Helper method to generate a new internal savegame id */ generateInternalId() { - const timestamp = ("" + Math_floor(Date.now() / 1000.0 - 1565641619)).padStart(10, "0"); - return ( - timestamp + - "." + - Rusha.createHash() - .update(Date.now() + "/" + Math.random()) - .digest("hex") - ); + return Rusha.createHash() + .update(Date.now() + "/" + Math.random()) + .digest("hex"); } // End diff --git a/src/js/states/ingame.js b/src/js/states/ingame.js index 6a400a14..eda9a9fb 100644 --- a/src/js/states/ingame.js +++ b/src/js/states/ingame.js @@ -198,8 +198,10 @@ export class InGameState extends GameState { this.core.initializeRoot(this, this.savegame); if (this.savegame.hasGameDump()) { + this.app.gameAnalytics.handleGameStarted(this.savegame); this.stage4bResumeGame(); } else { + this.app.gameAnalytics.handleGameStarted(this.savegame); this.stage4aInitEmptyGame(); } }