From c48d77b51d72f446ee4a0fa06980dab0fa5e2000 Mon Sep 17 00:00:00 2001 From: SplatterDash <95732227+SplatterDash@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:21:43 -0400 Subject: [PATCH 1/5] gamejolt pr - cne prevents leaks! --- .gitignore | 1 + building/libs.xml | 1 + project.xml | 4 + source/funkin/backend/assets/ModsFolder.hx | 11 +- source/funkin/backend/system/Flags.hx | 6 + source/funkin/backend/system/MainState.hx | 8 + source/funkin/backend/utils/GJUtil.hx | 319 ++++++++++++++++ .../backend/utils/GameJoltSecurityPublic.hx | 341 ++++++++++++++++++ source/hscript/Config.hx | 2 +- 9 files changed, 691 insertions(+), 2 deletions(-) create mode 100644 source/funkin/backend/utils/GJUtil.hx create mode 100644 source/funkin/backend/utils/GameJoltSecurityPublic.hx diff --git a/.gitignore b/.gitignore index 8b905e1643..7401c22170 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ addons/* dev-libs/ *.sum .vs/ +source/funkin/backend/utils/GameJoltSecurity.hx \ No newline at end of file diff --git a/building/libs.xml b/building/libs.xml index f78849581d..9cc20032b4 100644 --- a/building/libs.xml +++ b/building/libs.xml @@ -17,6 +17,7 @@ + diff --git a/project.xml b/project.xml index 3bcdd92c26..45a14243cd 100644 --- a/project.xml +++ b/project.xml @@ -66,6 +66,9 @@ + + + @@ -131,6 +134,7 @@ + diff --git a/source/funkin/backend/assets/ModsFolder.hx b/source/funkin/backend/assets/ModsFolder.hx index 0e33f999ed..e1db271b7b 100644 --- a/source/funkin/backend/assets/ModsFolder.hx +++ b/source/funkin/backend/assets/ModsFolder.hx @@ -3,6 +3,7 @@ package funkin.backend.assets; import flixel.util.FlxSignal.FlxTypedSignal; import funkin.backend.system.MainState; import funkin.backend.utils.CoolUtil; +import funkin.backend.utils.GJUtil; import haxe.ds.StringMap; import haxe.io.Path; import lime.text.Font; @@ -75,9 +76,17 @@ class ModsFolder { } public static function reloadMods() { - if (!__firstTime) + if (!__firstTime) { + #if GAMEJOLT_API + if (GJUtil.active) + GJUtil.logout(); + #end + FlxG.switchState(new MainState()); + } __firstTime = false; + + } /** diff --git a/source/funkin/backend/system/Flags.hx b/source/funkin/backend/system/Flags.hx index 2e61eec20c..f2a6cef704 100644 --- a/source/funkin/backend/system/Flags.hx +++ b/source/funkin/backend/system/Flags.hx @@ -28,6 +28,7 @@ class Flags { public static var MOD_NAME:String = ""; public static var MOD_DESCRIPTION:String = ""; public static var MOD_AUTHOR:String = ""; + public static var MOD_VERSION:String = ""; @:lazy public static var MOD_API_VERSION:Null = null; public static var MOD_DOWNLOAD_LINK:String = ""; public static var MOD_DEPENDENCIES:Array = []; @@ -43,6 +44,11 @@ class Flags { public static var MOD_REDIRECT_STATES:Map = []; + @:also(funkin.backend.utils.GameJoltSecurity.gameId) + public static var MOD_GAMEJOLT_GAME_ID:String = ''; + @:also(funkin.backend.utils.GameJoltSecurity.encryptedGameToken) + public static var MOD_GAMEJOLT_TOKEN:String = ''; + // -- Codename's Default Flags -- @:lazy public static var SAVE_PATH:String = haxe.macro.Compiler.getDefine("SAVE_PATH"); @:lazy public static var SAVE_NAME:String = haxe.macro.Compiler.getDefine("SAVE_NAME"); diff --git a/source/funkin/backend/system/MainState.hx b/source/funkin/backend/system/MainState.hx index 14988dddc4..205cebcaad 100644 --- a/source/funkin/backend/system/MainState.hx +++ b/source/funkin/backend/system/MainState.hx @@ -10,6 +10,7 @@ import funkin.backend.assets.ModsFolderLibrary; import funkin.backend.assets.ZipFolderLibrary; import funkin.backend.chart.EventsData; import funkin.backend.system.framerate.Framerate; +import funkin.backend.utils.GJUtil; import funkin.editors.ModConfigWarning; import funkin.menus.TitleState; import haxe.io.Path; @@ -162,6 +163,13 @@ class MainState extends FlxState { if (cast(lib, ZipFolderLibrary).PRELOAD_VIDEOS) cast(lib, ZipFolderLibrary).precacheVideos(); } + #if GAMEJOLT_API + if (FlxG.save.data.gameJoltArray != null) { + var gjDat:Array = FlxG.save.data.gameJoltArray; + GJUtil.attemptLogin(gjDat[0], gjDat[1]); + } + #end + var startState:Class = Flags.DISABLE_WARNING_SCREEN ? TitleState : funkin.menus.WarningState; // In this case if the mod we just loaded a compressed modpack, we can't edit or modify files without decompressing it. diff --git a/source/funkin/backend/utils/GJUtil.hx b/source/funkin/backend/utils/GJUtil.hx new file mode 100644 index 0000000000..56fa88050b --- /dev/null +++ b/source/funkin/backend/utils/GJUtil.hx @@ -0,0 +1,319 @@ +package funkin.backend.utils; + +/** + * This is how GameJolt API responses are formatted like. + */ +typedef Response = { + // General + success:Bool, + ?message:String, + // User Fetching + ?users:Array, + // Trophies Fetching + ?trophies:Array, + // Scores Fetching + ?scores:Array, + ?tables:Array, + ?rank:Int, + // Friends Fetching + ?friends:Array<{friend_id:Int}>, + // Data Store Fetching + ?keys:Array<{key:String}>, + ?data:String, + // Time Fetching + ?timestamp:Int, + ?timezone:String, + ?year:Int, + ?month:Int, + ?day:Int, + ?hour:Int, + ?minute:Int, + ?second:Int, + // Batch Reception + ?responses:Array +} + +/** + * The way the scores are fetched from your game API. + * + * @param score The display text of the Score. + * @param sort The Score value. + * @param extra_data If some extra data is attached to this Score, it'll be shown here. + * @param user The username of the User who achieved this Score, if it's a registered User. + * @param user_id The user ID of the User who achieved this Score, if it's a registered User. + * @param guest The name of the user who achieved this Score, if it's a guest user. + * @param stored A short description about when the Score was achieved by the User or Guest. + * @param stored_timestamp A long time stamp (in seconds) of when the Score was achieved by the User or Guest. + */ +typedef Score = { + score:String, + sort:Int, + extra_data:String, + user:String, + user_id:Int, + guest:String, + stored:String, + stored_timestamp:Int +} + +/** + * The way the score tables are fetched from your game API. + * + * @param id The ID of the Score Table. + * @param name The name of the Score Table. + * @param description The description of the Score Table. + * @param primary Whether if this is the Primary Score Table in your game (1) or not (0). + */ +typedef ScoreTable = { + id:Int, + name:String, + description:String, + primary:Bool +} + +/** + * The way the trophies are fetched from your game API. + * + * @param id The ID of the Trophy. + * @param title The title of the Trophy. + * @param description The description of the Trophy. + * @param difficulty The difficulty rank of the Trophy. + * @param image_url The link of the image that represents the Trophy. + * @param achieved Whether this Trophy was achieved or not, it can be a string if it was (with info about how much time ago it was achieved) or bool if not (false). + */ +typedef Trophy = { + id:Int, + title:String, + description:String, + difficulty:String, + image_url:String, + achieved:String +} + +/** + * The way the user data is fetched from the GameJolt API. + * + * @param id The ID of the User. + * @param type The cathegory the User is cataloged like in GameJolt. + * @param username The username of the User. (Also available for guests). + * @param avatar_url The link of the avatar of the User. + * @param signed_up A short description about how long the User have been in GameJolt. + * @param signed_up_timestamp A long time stamp (in seconds) of when the User signed up. + * @param last_logged_in A short description about the last time the User was found active in GameJolt. + * @param last_logged_in_timestamp A long time stamp (in seconds) of the last time the User logged in GameJolt. + * @param status The actual status of the User. + * @param developer_name The display name of the User. (Also available for guests). + * @param developer_website The website of the User. + * @param developer_description The description of the User. + */ +typedef User = { + id:Int, + type:String, + username:String, + avatar_url:String, + signed_up:String, + signed_up_timestamp:Int, + last_logged_in:String, + last_logged_in_timestamp:Int, + status:String, + developer_name:String, + developer_website:String, + developer_description:String +} + +/** + * An enum class to clasify Data Store update functions. + */ +enum DataUpdateType { + Add(n:Int); + Substract(n:Int); + Multiply(n:Int); + Divide(n:Int); + Append(t:String); + Prepend(t:String); +} + +/** + * An enum of every single command currently available to request to GameJolt API. + */ +enum RequestType { + BATCH(parallel:Bool, breakOnError:Bool, requests:Array); + DATA_FETCH(key:String, fromUser:Bool); + DATA_GETKEYS(fromUser:Bool, ?pattern:String); + DATA_REMOVE(key:String, fromUser:Bool); + DATA_SET(key:String, data:String, toUser:Bool); + DATA_UPDATE(key:String, operation:DataUpdateType, toUser:Bool); + FRIENDS; + TIME; + USER_AUTH; + USER_FETCH(userOrID:String); + SESSION_OPEN; + SESSION_PING(active:Bool); + SESSION_CHECK; + SESSION_CLOSE; + SCORES_ADD(score:String, sort:Int, ?extra_data:String, ?table_id:Int); + SCORES_GETRANK(sort:Int, ?table_id:Int); + SCORES_FETCH(fromUser:Bool, ?table_id:Int, ?limit:Int, ?betterThan:Int); + SCORES_TABLES; + TROPHIES_FETCH(?achieved:Bool, ?trophy_id:Int); + TROPHIES_ADD(trophy_id:Int); + TROPHIES_REMOVE(trophy_id:Int); +} + +/** + * GameJolt utility to help with GameJolt functionality. Use this class to determine if your player is logged into GameJolt. + * Will not do anything if there is no provided GameJolt token. + * + * # IMPORTANT + * If you wish to use this utility, please run your GameJolt game's security code through the Codename Engine + * encryption tool on Codename's website. + * Place the output of that into your modpack.ini under the flag `MOD_GAMEJOLT_TOKEN`. + * + * ## DO NOT PLACE YOUR SECURITY KEY RIGHT INTO THE MODPACK.INI!!!! THAT IS A SECURITY ISSUE!!!! + */ +class GJUtil +{ + /** + * Boolean to determine if our player logged in. + */ + public static var loggedIn:Bool = false; + + /** + * The username of the logged in user. + */ + public static var userName(default, set):String; + + /** + * Whether or not the GameJolt utility is operational. + * This cannot be set other than load operations. + */ + public static var active(default, null):Bool = false; + + /** + * Whether or not the utility is executing a call. + */ + static var executing:Bool = false; + + /** + * Helper function in case the session is lost in the middle of the game. + */ + public static var onLostSession:NullVoid> = null; + + /** + * Helper function to simplify the login process. + * @param name Username of user attempting to login. + * @param token User token of user attempting to login. + * @return Bool Whether the attempt was successfull or not. + */ + public static function attemptLogin(name:String, token:String):Bool + { + if(Flags.MOD_GAMEJOLT_GAME_ID != '' && Flags.MOD_GAMEJOLT_TOKEN != '') + active = true + else + return false; + + var ret:Bool = false; + userName = name; + GameJoltSecurity.user_token = token; + send(RequestType.SESSION_OPEN, false, function(err) { + userName = null; + GameJoltSecurity.user_token = null; + }, function(resp) { + trace('GameJolt logged in as ${userName}'); + ret = true; + openfl.Lib.application.onExit.add(onExitApp); + FlxG.signals.postUpdate.add(pingTimer); + }); + return ret; + } + + static function onExitApp(i:Int) + { + logout(); + } + + static var pingTime:Int = 0; + public static function pingTimer() + { + pingTime += 1; + if (pingTime < 10000) return; + pingTime -= 10000; + pingSession(); + } + + public static function pingSession() + { + send(RequestType.SESSION_PING(true), true, (str) -> { + trace('GameJolt session lost.'); + if (onLostSession != null) onLostSession(); + shutdownFunctions(); + active = false; + }); + } + + public static function logout() + { + if (!active) + return; + + shutdownFunctions(); + send(RequestType.SESSION_CLOSE, false, null, function(resp) { + trace('GameJolt account ${userName} logged out successfully.'); + userName = null; + GameJoltSecurity.user_token = null; + }); + } + + static function shutdownFunctions() + { + FlxG.signals.postUpdate.remove(pingTimer); + openfl.Lib.application.onExit.remove(onExitApp); + onLostSession = null; + } + + public static function send(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:Response->Void, ?onProgress:Array->Void) + { + if (executing || !active) + return; + executing = true; + + @:privateAccess + var resp:Response = GameJoltSecurity.handleRequest(async, call, onProgress); + executing = false; + if (resp.message != null && onError != null) + onError(resp.message); + else if (resp.message == null && onComplete != null) + onComplete(formatImages(resp)); + } + + static function formatImages(res:Response):Response { + if (res.users != null) + for (u in res.users) u.avatar_url = '${u.avatar_url.substring(0, 32)}1000${u.avatar_url.substr(34)}'.replace(".jpg", ".png") + .replace(".webp", ".png"); + if (res.trophies != null) for (t in res.trophies) { + var newUrl:String = ""; + if (t.image_url.startsWith('https://m.')) + newUrl = '${t.image_url.substring(0, 37)}1000${t.image_url.substr(40)}'.replace(".jpg", ".png").replace(".webp", ".png"); + else { + newUrl = "https://s.gjcdn.net/assets/"; + newUrl += switch (t.image_url.substring(24).replace(".jpg", "").replace(".webp", "")) { + case "trophy-bronze-1": "9c2c91d0"; + case "trophy-silver-1": "b46e352e"; + case "trophy-gold-1": "363ce2dc"; + case "trophy-platinum-1": "92e5330d"; + default: ""; + }; + newUrl += ".png"; + } + t.image_url = newUrl; + }; + if (res.responses != null) for (res2 in res.responses) res2 = formatImages(res2); + return res; + } + + static function set_userName(name:String):String + { + loggedIn = (name != null && name != ''); + return userName = name; + } +} \ No newline at end of file diff --git a/source/funkin/backend/utils/GameJoltSecurityPublic.hx b/source/funkin/backend/utils/GameJoltSecurityPublic.hx new file mode 100644 index 0000000000..3a51d6f1f2 --- /dev/null +++ b/source/funkin/backend/utils/GameJoltSecurityPublic.hx @@ -0,0 +1,341 @@ +package funkin.backend.utils; + +import hscript.IHScriptCustomBehaviour; +import funkin.backend.utils.GJUtil; +import funkin.backend.utils.GJUtil.RequestType; +import haxe.crypto.Md5; +import funkin.backend.utils.GJUtil.*; +import haxe.Http; +import haxe.Json; +import openfl.events.*; + +/** + * # A BIG MOTHERFUCKING WARNING + * + * This class handles the raw game keys for GameJolt keys. + * If the raw game keys are made public, people can mess with leaderboards, data, achievements, + * or whatever else is on the game page. + * + * As such, Codename Engine requires players to encrypt keys, and the method of + * encryption is non-disclosable for security reasons, so the original file + * used in distributed CNE builds cannot be shared. + * Instead, we provide this public-facing file for hardcoding purposes. + * + * To use this file, make a copy and rename the copy's filename and class name to + * `GameJoltSecurity.hx`. To modify the encryption method, go to the function + * `set_encryptedGameToken` and modify the code in the first half of the null + * check (`if (tok != null) {}`). + * + * **WE HIGHLY ENCOURAGE YOU TO COME UP WITH AN ENCRYPTION METHOD FOR GAMEJOLT KEYS.** + * HaxeFoundation's crypto package is installed with Codename, you can see the methods + * you can use (as well as the documentation) [here](https://github.com/HaxeFoundation/crypto). + * + * # ***DO NOT LET YOUR PLAYERS PUT THE RAW KEYS IN ANY SOFTCODED FILES!!! WE ARE NOT*** + * ***RESPONSIBLE IF YOU DON'T MAKE YOUR PLAYERS ENCRYPT THEIR KEYS AND THEIR STUFF*** + * ***GETS HACKED!!!!*** + * + * Also for security purposes, this class is unattainable via HScript. + * + * ~ SplatterDash + */ +@:noCustomClass +@:dox(hide) +abstract class GameJoltSecurityPublic implements IHScriptCustomBehaviour +{ + /** + * Token for the user if they're logged in. + */ + public static var user_token:String = ''; + + /** + * ID number for the current mod. + */ + public static var gameId:String = ''; + + /** + * The encrypted game token. Set using GAMEJOLT_ENCRYPTED_TOKEN in ini file. + */ + public static var encryptedGameToken(default, set):String; + + /** + * The unencrypted game token. It's insanely hard to get this variable. + */ + @:noPrivateAccess static var revealedGameToken:String; + + /** + * URL sent to GameJolt per request. + */ + @:noPrivateAccess static var url(get, never):String; + + /** + * The previous response created by the API client. Usually for just storage purposes. + */ + static var lastResponse:Response = {success: false, message: "No response yet."}; + + /** + * The current call being processed. + */ + static var curCall:Null = null; + + // hscript - thanks LJ :D + public function hget(name:String):Dynamic + { + return null; + } + + public function hset(name:String, val:Dynamic):Dynamic + { + return null; + } + + static function get_url():String + { + return sign('https://api.gamejolt.com/api/game/v1_2${parseType(curCall)}'); + } + + static function handleRequest(async:Bool = false, data:RequestType, ?onProgress:Array->Void):Response + { + if (encryptedGameToken == null || gameId == null) { + lastResponse = {success: false, message: 'Missing game token and/or game ID.'}; + curCall = null; + return lastResponse; + } + + curCall = data; + + if (async) { + var loader = new openfl.net.URLLoader(); + loader.addEventListener(Event.COMPLETE, function(complete) { + lastResponse = Json.parse(cast(loader.data, String)).response; + if (lastResponse.message != null) { + trace('Response Error: ${lastResponse.message}'); + } + + }); + loader.addEventListener(ProgressEvent.PROGRESS, progress -> { if (onProgress != null) onProgress([progress.bytesLoaded, progress.bytesTotal]);}); + loader.addEventListener(IOErrorEvent.IO_ERROR, function(ioError) { + lastResponse = {success: false, message: 'IO Error: ${ioError.text}'}; + }); + loader.addEventListener(SecurityErrorEvent.SECURITY_ERROR, (securityError) -> { + lastResponse = {success: false, message: 'Security Error: ${securityError.text}'}; + }); + loader.load(new openfl.net.URLRequest(url)); + return {success: false, message: "No response yet."}; + } else { + var loader:Http = new Http(url); + loader.onData = function(data) { + lastResponse = Json.parse(data).response; + if (lastResponse.message != null) + trace('Response Error: ${lastResponse.message}'); + }; + loader.onError = function(error) { + lastResponse = {success: false, message: 'Request Error: ${error}'}; + }; + loader.request(false); + } + curCall = null; + return lastResponse; + } + + static function parseType(request:RequestType, signed:Bool = false):String { + var command:String = ""; + var action:String = ""; + var params:Array<{name:String, value:String}> = []; + + switch (request) { + case BATCH(parallel, breakOnError, requests): + command = "batch"; + params.push({name: "parallel", value: '$parallel'}); + params.push({name: "break_on_error", value: '$breakOnError'}); + for (req in requests) params.push({name: "requests[]", value: parseType(req, true)}); + case DATA_FETCH(key, fromUser): + command = "data-store"; + params.push({name: "key", value: key.urlEncode()}); + if (fromUser) { + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + } + case DATA_GETKEYS(fromUser, pattern): + command = "data-store"; + action = "get-keys"; + if (pattern != null && pattern != "") + params.push({name: "pattern", value: pattern.urlEncode()}); + if (fromUser) { + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + } + case DATA_REMOVE(key, fromUser): + command = "data-store"; + action = "remove"; + params.push({name: "key", value: key.urlEncode()}); + if (fromUser) { + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + } + case DATA_SET(key, data, toUser): + command = "data-store"; + action = "set"; + params.push({name: "key", value: key.urlEncode()}); + params.push({name: "data", value: data.urlEncode()}); + if (toUser) { + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + } + case DATA_UPDATE(key, operation, toUser): + command = "data-store"; + action = "update"; + params.push({name: "key", value: key.urlEncode()}); + if (toUser) { + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + } + switch (operation) { + case Add(n): + params.push({name: 'operation', value: 'add'}); + params.push({name: 'value', value: '$n'}); + case Substract(n): + params.push({name: 'operation', value: 'substract'}); + params.push({name: 'value', value: '$n'}); + case Multiply(n): + params.push({name: 'operation', value: 'multiply'}); + params.push({name: 'value', value: '$n'}); + case Divide(n): + params.push({name: 'operation', value: 'divide'}); + params.push({name: 'value', value: '$n'}); + case Append(t): + params.push({name: 'operation', value: 'append'}); + params.push({name: 'value', value: t.urlEncode()}); + case Prepend(t): + params.push({name: 'operation', value: 'prepend'}); + params.push({name: 'value', value: t.urlEncode()}); + } + case FRIENDS: + command = "friends"; + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + case TIME: + command = "time"; + case USER_AUTH: + command = "users"; + action = "auth"; + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + case USER_FETCH(userOrID): + command = "users"; + var letters:Array = "ABCDEFGHIJKLMNÑOPQRSTUVWXYZ_-".split(""); + if (letters.filter(l -> userOrID.contains(l.toUpperCase()) || userOrID.contains(l.toLowerCase())).length > 0) + params.push({name: "username", value: userOrID}); + else + params.push({name: "user_id", value: userOrID.replace(",", "%2C")}); + case SESSION_OPEN: + command = "sessions"; + action = "open"; + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + case SESSION_PING(active): + command = "sessions"; + action = "ping"; + params.push({name: "status", value: active ? "active" : "idle"}); + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + case SESSION_CHECK: + command = "sessions"; + action = "check"; + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + case SESSION_CLOSE: + command = "sessions"; + action = "close"; + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + case SCORES_ADD(score, sort, extra_data, table_id): + command = "scores"; + action = "add"; + params.push({name: "score", value: score}); + params.push({name: "sort", value: '$sort'}); + if (extra_data != null && extra_data != "") + params.push({name: "extra_data", value: extra_data.urlEncode()}); + if (table_id != null) + params.push({name: "table_id", value: '$table_id'}); + if (user_token != "") { + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + } else + params.push({name: "guest", value: GJUtil.userName}); + case SCORES_GETRANK(sort, table_id): + command = "scores"; + action = "get-rank"; + params.push({name: "sort", value: '$sort'}); + if (table_id != null) + params.push({name: "table_id", value: '$table_id'}); + case SCORES_FETCH(fromUser, table_id, limit, betterThan): + command = "scores"; + if (table_id != null) + params.push({name: "table_id", value: '$table_id'}); + if (limit != null) + params.push({name: "limit", value: '$limit'}); + if (betterThan != null) + params.push({name: betterThan < 0 ? "worse_than" : "better_than", value: '${Math.abs(betterThan)}'}); + if (fromUser) { + if (user_token != "") { + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + } else + params.push({name: "guest", value: GJUtil.userName}); + } + case SCORES_TABLES: + command = "scores"; + action = "tables"; + case TROPHIES_FETCH(achieved, trophy_id): + command = "trophies"; + if (achieved != null) + params.push({name: "achieved", value: '$achieved'}); + if (trophy_id != null) + params.push({name: "trophy_id", value: '$trophy_id'}); + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + case TROPHIES_ADD(trophy_id): + command = "trophies"; + action = "add-achieved"; + params.push({name: "trophy_id", value: '$trophy_id'}); + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + case TROPHIES_REMOVE(trophy_id): + command = "trophies"; + action = "remove-achieved"; + params.push({name: "trophy_id", value: '$trophy_id'}); + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + } + + var urlSection:String = '/$command${action != "" ? '/$action' : ""}?game_id=${gameId}${[for (p in params) '&${p.name}=${p.value}'].join("")}'; + if (signed) + urlSection = sign(urlSection).urlEncode(); + return urlSection; + } + + /** + * Setter function for encrypted game token. Also sets revealed game token. + * @param tok + */ + static function set_encryptedGameToken(tok:String) + { + if (tok != null) { + // Encryption method goes here. + revealedGameToken = tok; + } else { + revealedGameToken = null; + } + return encryptedGameToken = tok; + } + + /** + * Signs a piece of URL with Md5. + * @param daUrl The old URL piece. + * @return The new URL piece. + */ + static function sign(daUrl:String):String { + var urlToEncode:String = daUrl + revealedGameToken; + return '$daUrl&signature=${Md5.encode(urlToEncode)}'; + } +} \ No newline at end of file diff --git a/source/hscript/Config.hx b/source/hscript/Config.hx index 3acd554b16..7c8e03f105 100644 --- a/source/hscript/Config.hx +++ b/source/hscript/Config.hx @@ -29,7 +29,7 @@ class Config { // Incase any of your files fail // These are the module names public static final DISALLOW_CUSTOM_CLASSES = [ - + "funkin.backend.utils.GameJoltSecurity", // don't want people getting those gamejolt keys! ]; public static final DISALLOW_ABSTRACT_AND_ENUM = [ From 7f0f6f2c00d9ac35fdeac7c95c1c49fa7c3c916b Mon Sep 17 00:00:00 2001 From: SplatterDash <95732227+SplatterDash@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:24:52 -0400 Subject: [PATCH 2/5] quick save data patch saves login information to save file (with options) when successfully logged in --- source/funkin/backend/utils/GJUtil.hx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/source/funkin/backend/utils/GJUtil.hx b/source/funkin/backend/utils/GJUtil.hx index 56fa88050b..cc8ee81e37 100644 --- a/source/funkin/backend/utils/GJUtil.hx +++ b/source/funkin/backend/utils/GJUtil.hx @@ -223,6 +223,8 @@ class GJUtil ret = true; openfl.Lib.application.onExit.add(onExitApp); FlxG.signals.postUpdate.add(pingTimer); + FlxG.save.data.gameJoltArray = [userName, token]; + FlxG.save.flush(); }); return ret; } From 773458e314fddb4d50db5116c962ede0af9d0e75 Mon Sep 17 00:00:00 2001 From: SplatterDash <95732227+SplatterDash@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:51:00 -0400 Subject: [PATCH 3/5] checkpoint commit! - tested/modified settings to ensure both GameJoltSecurity and NEW GameJoltData are inaccessible via Hscript - NEW SecretMacro - reads .env file if provided, and generates new one if missing (alongside new randomly-generated AES key if missing) - new method of initializing GameJolt for mods! > place Game ID in modpack.ini for GAMEJOLT_GAME_ID > place owner username/user token, raw game token (if you're worried keep reading), trophies, leaderboards, and data items in gamejolt.xml in data/config folder > load mod in-game - owner/game token info will be wiped from xml, game gives xml key, and game sets everything in xml (except game token and user token) in global data store for game > running a mod with gamejolt relies on global data store items for trophies, leaderboards, etc - only page owner can update this global key > for those worried about keys - each key can hold 16 MB, and in testing a global key with 6 trophies, a leaderboard, one custom data setting, and owner items takes up around 0.7 KB - some starts on GameJolt menus - WIP at time of commit encrypting/protecting game security keys is complete! part two now is limiting the attack surface for API calls - most of the calls that can be made now for softcoding are fetch calls and a planned GJUtil function for custom trophies (there are some that can be inputted for "defined" events - loading the mod for the first time, getting a first FC, completing all songs, completing a week - but these are inaccessible from softcode). still a bunch planned, especially with menus - but it's a fun little commit hehe --- .gitignore | 2 +- assets/data/config/menuItems.txt | 3 +- assets/images/menus/gamejolt-icon.png | Bin 0 -> 7063 bytes source/funkin/backend/system/Flags.hx | 6 +- source/funkin/backend/system/MainState.hx | 8 +- .../backend/system/gamejolt/GameJoltData.hx | 386 ++++++++++++++++++ .../gamejolt/GameJoltSecurity.hx} | 124 ++++-- .../backend/system/macros/SecretMacro.hx | 104 +++++ source/funkin/backend/utils/GJUtil.hx | 150 +++++-- source/funkin/menus/MainMenuState.hx | 15 + .../menus/gamejolt/GameJoltCompleteScreen.hx | 57 +++ .../menus/gamejolt/GameJoltLoginSuccess.hx | 23 ++ .../menus/gamejolt/GameJoltLoginWindow.hx | 52 +++ source/funkin/menus/gamejolt/GameJoltMenu.hx | 47 +++ .../menus/gamejolt/GameJoltTokenInfoWindow.hx | 32 ++ source/hscript/Config.hx | 5 +- 16 files changed, 937 insertions(+), 77 deletions(-) create mode 100644 assets/images/menus/gamejolt-icon.png create mode 100644 source/funkin/backend/system/gamejolt/GameJoltData.hx rename source/funkin/backend/{utils/GameJoltSecurityPublic.hx => system/gamejolt/GameJoltSecurity.hx} (78%) create mode 100644 source/funkin/backend/system/macros/SecretMacro.hx create mode 100644 source/funkin/menus/gamejolt/GameJoltCompleteScreen.hx create mode 100644 source/funkin/menus/gamejolt/GameJoltLoginSuccess.hx create mode 100644 source/funkin/menus/gamejolt/GameJoltLoginWindow.hx create mode 100644 source/funkin/menus/gamejolt/GameJoltMenu.hx create mode 100644 source/funkin/menus/gamejolt/GameJoltTokenInfoWindow.hx diff --git a/.gitignore b/.gitignore index 7401c22170..3138e6d0ed 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,4 @@ addons/* dev-libs/ *.sum .vs/ -source/funkin/backend/utils/GameJoltSecurity.hx \ No newline at end of file +.env \ No newline at end of file diff --git a/assets/data/config/menuItems.txt b/assets/data/config/menuItems.txt index a8ec55b16e..677cc54593 100644 --- a/assets/data/config/menuItems.txt +++ b/assets/data/config/menuItems.txt @@ -1,4 +1,5 @@ story mode freeplay options -credits \ No newline at end of file +credits +gamejolt \ No newline at end of file diff --git a/assets/images/menus/gamejolt-icon.png b/assets/images/menus/gamejolt-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..70b5055600fa1aa287c13eb1a1d5fa50425cac2e GIT binary patch literal 7063 zcmZu#bzD?kw?0G1iy$eIDiRWs(j_1u-AZ>ebk|VQQUW@3sx&xsh%`tIDJ3Ni%m5NY z*B#&ZzV~w|kfu z*g;NC)4|FL0N7K!Q^ZxUWhtXRv&6yWxnN|q-=6l~4`+`lk>^!|#oSJqXUTHDzujje zEE`^FZgsnc%q58G8ruce2v=4U=eCoISz_eT>)ZDWKMj9R67b{^uCwYeBhPedftTHh(u-*B|Pddui48Md=b=w)W+pm{T5Ck@!Rku3w_uhiVAFdLv zo;nWdw{a|%&`i8s4PK3TfKS<>8BjW!6nE$yqPNXLk+xzWS$|17dTHLi@}euED>&oj z%;&KpF!?AI!nWjiV7vFBYy$gT+9570ZA&FbE&huI;meII3k8XmOP5C?YXdrDuJTiL zUL(pv@?(Q`+FH#wqmx3<6p>5aTJ|A(ZJ8nVbK!>5hf=pBs(lE;0!28VTymyPo@Q-M zznW>pXF$1md>`juaBBfA`TEQ&hbn{Kg>*v@R`wdQ2r3x&%%^ix#3psB(}8?u%_m~) zy3bZy6t9o$W?E0Z3^6zmx+%T%1OQ_4-w$YFDdZ9WXvdW0Wp(}LcXEh>C>3)1ZzPjc zS??Y2N@N`2HbJoPWJ!-6kS>{=thZFfF&8#@EbcTjwJG3i1XVot(UPTf2c4`kp3iO5 zCdO^zYnU{Pr$uyA+r{*)~k>{4$)ty#*^gWz#C=lZ0O;bw3z^+G*UB2 zoTr~7L&>X@@|XA;Wt_Ie>aWWX#cWxXaqufHWn;B+BQg0rO@9Oeu1xDyNvl8pg(olY zWViC#Pn48|R<9SN$3XyvLZ_ILVnW~5zZ#Crht?jIR#dW87Fd_oi=}vuy@8CCrE1^& z8ZVGDR8xEYo=Ek9^VO_b=+b`eC^CwBR?lO1gq>Eg3}V)(9_;RJG2KgA;_3d0KY5^x zj8WXDLiYiJq1SZRkaNKBCcm(-aBd*it_`PH7jFr-!S+6)bfJ1_G;U6Ngu!S9MtLgHP=CfMIzI(26xh@)FvjaLbr}B%KNlX zh;a{fLsulOUsHeO8J8_>Wk^uphiy%b;@8h-q#LJWQu1zx_2EDHXqeH}-CKtAuBR>Q z$4~9Uo4-zX*Q;P;Qo2jWTvQ8cFPkO#WIIwfJ>}78R0UVH6HWgeGM8T>ODQQZ!?4$t zzOF%?vDrqnOq=vj|KmBkNyb!V6@p+Xgv|`>4-p}jH1Rkk#X+u-2G=_S&n>JmUd!v* z{5T6wu-2Cg+sF_aJ=(wBxOFEU$)=blU8R z$VJmWaS?ksf1APlN6!le#k%JQo*b+J_OH%JeuT55BSEt}5_vcKS zef!Y=1GfoZG>%`TlU;p|lah5=uGRZnD---|MY#Fwpwx{t>GhL5dM2H>@~Znzef94I zMIUTb^FSh97|ds;B)tuUj*w?I2t=qK`Uikc>Ys*t8+0t6`Qs&_n#^A+zsb!x$r^2f z-|{}GMB*Fa?$PMgMTcJKIG(lzA`k{4^-YJtBN=?K%lcZVdq0Ijn~be@$TdY$eB)=* zeYeR70gcywTraA!iew}3w5+U(Owl?L!=7z~@_N6nLf(akHQ8o&#cMw1p+51AJ^UzG z=f`~uPi;xX-QC^rENyto?^%GCa#DvkoO8~9zlA`$DA2t$^r24r4|p1`R_I(Qlm?zy(BKUw5WvwpFLzI<8`)B@GrpbIb5=_ zvB?D^XobAxqHjrp&4+SF0~yGOqF(Tb7`?N08ap%t0YIpvcO(?IZC?3fYMm?lDwELQ~ zS$3Trtz8*&^ZjK%v4zev;+0X(&<{p{(@aG{S=poZ;tM6KEdnpV3(_CUx~6+lzTxmr zObH-j9$`&}!Rl2CK4vyQQ8Z#`{utJNp{;ns?;l_J>J?s5baBroF-3-7M6Fl$6>tix zl9H0CiHSlQ$@J()f*?wn*Y9Na2`*>O#@?Op?yvVJ7Mg)c8Cc0}$#9dmSo1>`^$rGq zq`kGJ-@myCuCvXy$GTKBuk;dyl{yw0lpjcdF-vQ z{Y%2W0S(9Wl8}{`TE#r1^5yH1SgNmeXds4^y%7@y`BS;Ff*{v-vVAD6C8;U#4>3#n z=(V1zN~B0FMZ6&vJ4X)7L-n)71SHVNCi3J!1*O~wkC@!UE5JS&r=!G@=64vg6hcbI z-A@_dfR~;0d(;8yi!s-)3hoB7c*j1YkO7G-Qn5zo9@ja&)bVodZL=n30dSLF3iv&~ zEy|~Ab*}aD=sS}k9BB%p6NAuCfQAcv9v2Yi10MN6Q;z|%k`YesA2H2B-VB4{kOKmn z(TU@4-0cRtL6$W+5K@n?hjp+_E_u?tV2A8Jyy@3LE-dIJ*#nG zzYkj`g!u#>X-K4_&0L)gbnLO9uyDMKkJBHiNfE#g5woEEsOr+vXBrVFYZhFp00aO? zo5B{Somg|Y?GpS}5~Xc}#G$GWUiZwO?oH~_!#aE&<8YH-ftek+UPs~qWB?#M?oL@c zKKWTI(Ce`H0H08MHckB-_{CJ?-Nj%_doUd^}XHx=K9JaXA@^O+noRs zL6CP8s;>YSE*zBOCwF4I6Y0bSemsSCtDpUN2F`_6de0+4lxlYn?fmIh2-P zo}f0bl1AYPtrlH&5<&U2jR=|0^WMWWAprw@@)88%>X|1eSxPezX-@Hv0utrMjq5GLy+!BKAw6ZO)s;kFig zny3BPWPuHJr0Cc5&GzxV#JNrmi8knAj?CjPetJVccFVpA+F}b-6utS{sypx z$5k9nCHCDL)X`BsAp+F#@G`yf?bnO3-T{V$4`Z}#aq=e|=$5CZblrRySpgY921M9- zeuUBxEv$X;CNuN>a~@h8g5Me66!!WUEf)|zu#Oe53+4Li!jnS{|FAm54$pE`m=5fp zUU2NsF>3MpXkt)BSaOR3U2a2bVNiyB7w+m zizQ`h;hBDHU*mRLNpUkREx7aK=wpS!uHU{4csJbraAu?_5mgD=`#|-(?jV=CwL3ar z-a@@ww7De?{vZK<>%o-8p9>L*Geu@m0+X~<)ka@qE96d_! zrITdXPs5CBAXJ8Jeh>1QZP`C3JS0?|#%(MWENHcT;#`s=VE5YAb-8B-<~bbTmr9J~ z#uGYc!L~Dw|Et*29qN0_b95_sb0kkXSAZYcCeE%8LL(W?l`k;&1|2G4U8s~Vfv4(MVUa} zzM<2vv|?~O+Tx-mHM5k4{s$!jv}+>m&s()l+&hFzQ-HPu9p_ehDAAh8_L%l!NsLsQcv~P2%fDe_TEJT@@MCmk+rc75Jc{z@*aVV z9U{epSyJVW_T$RHDIpN)g0$E1MY_dK6cHWCyCDjeGQ`2Zn;Hg15O9XXW!d(@KI%tU zQL3;gSs0pj4q`{vi-p~1m~1NXdupr!SEJE5otMCs%+w=Y)sJ&>q)y;kc=S$_{Q~@c zsvfxW)i?Gg;oN=f>B6**QYinHs-bFdg~luD;rY&NSum+Ok`E2~C;fkXqI*HcOUL)@ zR5D}*7e}U-*-&k1lb=EFWm(qlPUCVZt$?k4AVZ%Oj%5ex4+4ndo{q06^9H64MshLt zxa~*TrOrQTg8?rr(BB1RPM?pOJ8&%3$O}=lvRbb+ZQh4M?`Bnf&uT+eUa6YDT15?u90e?#5y{O$iQ{B%EV`JRKwp-nQfQ1hl_$d8Pu1=w7k#K zGObDSZ_YWGAQ^t95gModX6Gery4(Q8_|0!GzaaC1kd#EsosRjr=o<29XJNLg+?Vvv zlf$26idpD3^^ho(_c}RYcjfCI?Cb!$BMbe<28%LT;9P=rb1Wh{!oO*a834fMKLgL! z6b^fE9`-7KPf_aGwJ}l-?am7qmogrz10v@+d(IP88eFfuPSH^=Sm7Uh{)-8grk~OW zpCsPU>&v=7Mv!5~rm%9k4 z0l*6c0yF{4^a}G(0s!+E=C}p8QMHna*;-#{qXEDzr8k~Ng+4ElHxmWHkljjCFZoo3)Aqd7Wyl zeAiLnNSDCXJ$4|uQJ%uRrD4s|P|&}{Y1Y}rrR4U5XXB$e;!DFwOUB(OVxW^l5;z&d ze)P!W3i3#U8$S{Y$cKbs1pl7?VRW#wAJ|ZL9{DrpvB(B^kxlx?S-Qw*s3HdAT8RaE zha?8C0?=}x&CWX)e4!ufQgo**Yo{@j8`;U5nm5RshNN(*&n4xAVZBfDbsQ4=H@}y= z<~wAn*1R}rvpL=NR`T8cCYxy*z)=r_z4*L5qu=_nJtx|c&f{)07Vpe!3*pD?jQ3;v z){9STxgl0hd(%H=aiS@|d&bD$cln|QU%i)#@{Z!e7<&hUlV;BY1}>w!p0lV;5M3n12H;0zdAn( zELBWmOiHd#wD0vbI0!T*zy&168e2|^K^wsmp?Q`H%gOiT7CW48j?L3M{%<}25GQME zY6zbXacFZ`1w$LhPv~fkXS>`;zx`mIfsSBqjUWjn0q;3Hvfj(Y?Xw8Sly*V;30!IZ zg_$~tI6h{LkSUSj?4jD7=8=h1-rA?guX2)G%ikFk_^nD2Id{@N6pN^bhVCd{3OPEg z8E%jHK3mSqkJ_xU?2Vhj+(&v@h_To@=D&SAHMu^fiqE8&{npoABw7H7Jn(z_L*EA0 zQwVF=)e&P%gqps8oa@fPk+B&^n5X4G_L$q@QElRn(pR#@?7@C0T`s{#Oh;SZk+1Sv z(+jCNIgCA6Y4YK0;6)hLio+E@3GEBT_v!SF6^SZaB8Dxu>g(%!_4M?5pQM_OGjf^n zQxE|>0Pw%!1Cd~Rpz{8XmBwD?#o|!U>VTBt{yqbxiu|w!Hh-acr-o#U0tb&GcY0)! za&Zm6m`6g&4*}_hQOekW48qF5Y|6YSWzk&qR4gyFAK&0`*9)o7k>Bh-1vbVsiSP|R zXCMy_-NZo}=&Q#?b?qOCZbIE}dU2=cVd?S;=pN$94`2;nmBX_- z`usq!k;?K_D|g1v!(Q}ibXNcPVRh2RP=C;nUzY|=c5B|X%{eEF(c57P@)>%(5&4TFQI7rV@D`L{;P*vhHu^&HfVC?b0!V z)}>xeu?5{r*M3)`qugup@~SmQqiBp#CJb`+hA^aEHn{Z=Wiom=!1X5nEgl-a^6B-s z@W^s+UCp($w|{=T!{X{9U>Kka4G^o8X=CUX3MX$E-HNq^E49HaldA&$hnRT4hW!h1UuCcA;H?0dE*^uqk1A&5N`3lm>Ib`J0M=gR`gy$WpqjrbfwjTD}n*AiQHG z{WX27jnAsDNixR(moSVt`4{EJP$D%=EBFOpCh5Pn{uKm&SqZzy?yG5k1v1iP3f?}` z!KjaI5%{Eaxsrh`k~zY3hw^KTT8G(Qoj%NZ=Y?C(a0srf&hFi%BcG(F$_hiK)4gD@t7)9BQ*hs{NZX zqFKfG6zrlT?eA*>X9@OX*#38r846NCr!~4}X1{D1Z5Vy_+<>)PEC0p)A8okLWXG`D zPS@hYICM%QyH;y9U|%!1t%QVxGD;77d>dVt!6!ZnUC~^nw}siL(LvZ+_?m$7_Cn)W zrV@*D`WK9=#h8JKu&crF`e&jPTsI6L|LXC-5jTgny~TKOwDPzS9^3s;?eV0GiwjAW zadVw~1?*Ex)BCEb(cK;JPm4;DjSGVqPGFvB#h4N5lAS|a`Qd{-t1s!Oaok<(|3~r) z(OKOcC3>^^+-OVASW1NyBSXVydyOSes^(OyUzwGSHx|FUUjBPxkPsbco06 = []; - @:also(funkin.backend.utils.GameJoltSecurity.gameId) + @:also(funkin.backend.system.gamejolt.GameJoltSecurity.gameId) public static var MOD_GAMEJOLT_GAME_ID:String = ''; - @:also(funkin.backend.utils.GameJoltSecurity.encryptedGameToken) - public static var MOD_GAMEJOLT_TOKEN:String = ''; + @:also(funkin.backend.system.gamejolt.GameJoltSecurity.encryptedGameToken) + public static var MOD_GAMEJOLT_ENCRYPTED_TOKEN:String = ''; // -- Codename's Default Flags -- @:lazy public static var SAVE_PATH:String = haxe.macro.Compiler.getDefine("SAVE_PATH"); diff --git a/source/funkin/backend/system/MainState.hx b/source/funkin/backend/system/MainState.hx index 205cebcaad..5d920352b3 100644 --- a/source/funkin/backend/system/MainState.hx +++ b/source/funkin/backend/system/MainState.hx @@ -13,6 +13,7 @@ import funkin.backend.system.framerate.Framerate; import funkin.backend.utils.GJUtil; import funkin.editors.ModConfigWarning; import funkin.menus.TitleState; +import funkin.menus.gamejolt.GameJoltCompleteScreen; import haxe.io.Path; @@ -164,13 +165,10 @@ class MainState extends FlxState { } #if GAMEJOLT_API - if (FlxG.save.data.gameJoltArray != null) { - var gjDat:Array = FlxG.save.data.gameJoltArray; - GJUtil.attemptLogin(gjDat[0], gjDat[1]); - } + GJUtil.init(); #end - var startState:Class = Flags.DISABLE_WARNING_SCREEN ? TitleState : funkin.menus.WarningState; + var startState:Class = /**GameJoltData.freshStart ?**/ GameJoltCompleteScreen /**: (Flags.DISABLE_WARNING_SCREEN ? TitleState : funkin.menus.WarningState)**/; // In this case if the mod we just loaded a compressed modpack, we can't edit or modify files without decompressing it. if (Options.devMode && Options.allowConfigWarning && !isZipMod) { diff --git a/source/funkin/backend/system/gamejolt/GameJoltData.hx b/source/funkin/backend/system/gamejolt/GameJoltData.hx new file mode 100644 index 0000000000..7c87d8a3db --- /dev/null +++ b/source/funkin/backend/system/gamejolt/GameJoltData.hx @@ -0,0 +1,386 @@ +package funkin.backend.system.gamejolt; + +import haxe.xml.Access; +import haxe.io.Bytes; +import haxe.crypto.Aes; +import haxe.crypto.mode.Mode; +import haxe.crypto.padding.Padding; +import haxe.Json; +import sys.io.File; +import sys.FileSystem; +import flixel.input.keyboard.FlxKey; +import funkin.backend.utils.GJUtil; +import funkin.backend.utils.GJUtil.ScoreTable; +import funkin.backend.system.Controls.Control; +import funkin.backend.system.gamejolt.GameJoltSecurity; +import funkin.backend.assets.AssetSource; + + +typedef CNEGameJoltData = { + ownerU:String, + ownerI:Int, + defTrophies:Map, + cusTrophies:Map, + leaderboards:Map, + addlData:Map>, +} + +typedef GameJoltUserData = { + controls:Map>, + options:Map, + scores:Dynamic, + misc:Map +} + +typedef GJTrophyData = { + id:Int, + ?require:Int, + ?except:Array, + ?hidden:Bool, + ?weekName:String, +} + +class GameJoltData +{ + public static var freshStart(default, null):Bool = false; + + public static var ownerUsername:Null = null; + + public static var ownerUserId:Null = null; + + public static var leaderboards:Map = []; + + public static var definedTrophies:Map = []; + + public static var definitions:Array = ['open-first', 'friday-night', 'week', 'complete-all', 'fc-first', 'fc-all']; + + /** + * Custom trophies that mods may want to implement outside of the usual suspects. + * Unfortunately, these could be easy to cheese. + */ + public static var customTrophies(default, null):Map = []; + + public static var earnedTrophies:Map = []; + + public static var dataToInclude:Map> = []; + + static var xmlPath:String = Paths.xml('config/gamejolt'); + + static var helpText:String = ''; + + #if GAMEJOLT_API + public static function loadAdminData() + { + Logs.trace(Paths.assetsTree.getSpecificAsset(xmlPath, "TEXT", AssetSource.MODS)); + // get xml + var access = getGJX(); + + if (access == null) + return; + + // return if owner and token nodes are absent + if (!access.hasNode.owner || !access.hasNode.token) { + Logs.trace('Missing owner or token node from gamejolt.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + return; + } + + if (!access.node.owner.has.username || !access.node.owner.has.token) { + Logs.trace('Missing username or user token in gamejolt.xml owner node!', ERROR, LIGHTGRAY, 'GameJolt'); + return; + } + + // encrypt token + encryptToken(access.node.token.innerData); + + // + if (!GJUtil.attemptLogin(access.node.owner.att.username, access.node.owner.att.token, true, true)) { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("Unable to log in user "), + Logs.logText(access.node.owner.att.username, GREEN), + Logs.logText(' for data upload.') + ], ERROR); + return; + } + + ownerUsername = access.node.owner.att.username; + GameJoltSecurity.sendTrusted(USER_FETCH(ownerUsername), false, function(err) { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("Unable to obtain mandatory user ID data from user "), + Logs.logText(ownerUsername, GREEN), + Logs.logText(' for data upload: ${err}') + ], ERROR); + ownerUsername = null; + GJUtil.logout(false, true); + }, function(resp) { + ownerUserId = resp.users[0].id; + }); + + if (ownerUserId == null) + return; + + setGlobalData(true, () -> { + GJUtil.logout(false, true); + var overwriteXml:Xml = access.x; + for (own in access.x.elements()) { + if (own.nodeName == "owner" || own.nodeName == "token") + overwriteXml.removeChild(own); + } + + try { + // FileSystem.deleteFile(Paths.assetsTree.getSpecificPath(xmlPath, AssetSource.MODS)); + File.saveContent(Paths.assetsTree.getSpecificPath(xmlPath, AssetSource.MODS), helpText + '\n' + overwriteXml.toString()); + } catch(e) { + Logs.trace('Error creating new XML file: ${e}', ERROR, LIGHTGRAY, "GameJolt"); + } + freshStart = true; + }, access); + } + + public static function loadGlobalData():Bool + { + var ret:Bool = false; + GameJoltSecurity.sendTrusted(DATA_FETCH('CNE_DATASTORE', false), true, function(err) { + Logs.trace('Unable to obtain global mod data. GameJolt API turning off automatically.', ERROR, LIGHTGRAY, 'GameJolt'); + GJUtil.logout(false, true); + }, function(resp) { + var daDat:CNEGameJoltData = cast Json.parse(resp.data); + ownerUsername = daDat.ownerU; + ownerUserId = daDat.ownerI; + definedTrophies = daDat.defTrophies; + customTrophies = daDat.cusTrophies; + leaderboards = daDat.leaderboards; + dataToInclude = daDat.addlData; + ret = true; + }); + return ret; + } + + //region Set CNE Glob'ls + public static function setGlobalData(cleanSet:Bool = false, ?callback:Void->Void, ?data:Access) + { + if (data == null) { + data = getGJX(); + if (data == null) { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("Unable to locate "), + Logs.logText("gamejolt.xml", GREEN), + Logs.logText(' in '), + Logs.logText("data/config", GREEN), + Logs.logText(' folder!'), + ], ERROR); + return; + } + } + + // With all of these nodes, it's important to make sure that + // what we're importing a) exists, and b) isn't just a blank string. + + for (trophyDef in data.node.trophies.nodes.defined) { + if (!trophyDef.has.def || !trophyDef.has.id || trophyDef.att.def == '' || trophyDef.att.id == '') + continue; + + if (!definitions.contains(trophyDef.att.def)) + customTrophies.set(trophyDef.att.def, { + id: Std.parseInt(trophyDef.att.id), + require: (trophyDef.has.require && trophyDef.att.require != '') ? Std.parseInt(trophyDef.att.require) : null, + except: (trophyDef.has.except && trophyDef.att.except != '') ? trophyDef.att.except.split('//') : null, + hidden: (trophyDef.has.hidden && trophyDef.att.hidden != '') ? (trophyDef.att.hidden == 'true') : null, + weekName: null, + }); + + if (trophyDef.att.def == 'week' && (!trophyDef.has.weekName || trophyDef.att.weekName == '')) + continue; + + definedTrophies.set(trophyDef.att.def + (trophyDef.att.def == 'week' ? '-${trophyDef.att.weekName}': ''), { + id: Std.parseInt(trophyDef.att.id), + require: (trophyDef.has.require && trophyDef.att.require != '') ? Std.parseInt(trophyDef.att.require) : null, + except: (trophyDef.has.except && trophyDef.att.except != '') ? trophyDef.att.except.split('//') : null, + hidden: (trophyDef.has.hidden && trophyDef.att.hidden != '') ? (trophyDef.att.hidden == 'true') : null, + weekName: (trophyDef.att.def == 'week' && trophyDef.has.weekName && trophyDef.att.weekName != '') ? trophyDef.att.weekName : null, + }); + } + + for (trophyDef in data.node.trophies.nodes.custom) { + if (!trophyDef.has.def || !trophyDef.has.id) + continue; + + customTrophies.set(trophyDef.att.def, { + id: Std.parseInt(trophyDef.att.def), + require: (trophyDef.has.require && trophyDef.att.require != '') ? Std.parseInt(trophyDef.att.require) : null, + except: (trophyDef.has.except && trophyDef.att.except != '') ? trophyDef.att.except.split('//') : null, + hidden: (trophyDef.has.hidden && trophyDef.att.hidden != '') ? (trophyDef.att.hidden == 'true') : null, + weekName: null, + }); + } + + for (leaderboard in data.node.leaderboards.nodes.song) { + if (!leaderboard.has.id || !leaderboard.has.name || leaderboard.att.name == '' || leaderboard.att.id == '') + continue; + + leaderboards.set(leaderboard.att.name + (leaderboard.has.diff ? '-${leaderboard.att.diff}' : '') + ' (${leaderboard.has.vari ? leaderboard.att.vari : 'Default'})', Std.parseInt(leaderboard.att.id)); + } + + for (dat in data.node.data.nodes.value) { + if (!dat.has.name || dat.att.name == '') + continue; + + var location:String = (dat.has.inSave && dat.att.inSave != '') ? dat.att.inSave : "FlxG"; + var curVars:Array = dataToInclude.exists(location) ? dataToInclude.get(location) : []; + curVars.push(dat.att.name); + dataToInclude.set(location, curVars); + } + + var sendOut:CNEGameJoltData = { + ownerU: ownerUsername, + ownerI: ownerUserId, + defTrophies: definedTrophies, + cusTrophies: customTrophies, + leaderboards: leaderboards, + addlData: dataToInclude, + }; + + Logs.trace('Sending global data...', INFO, LIGHTGRAY, "GameJolt"); + + var success:Bool = true; + GameJoltSecurity.sendTrusted(DATA_SET('CNE_DATASTORE', Json.stringify(sendOut), false), false, function(err) { + Logs.trace('Unable to upload global GameJolt data: ${err}', ERROR, LIGHTGRAY, 'GameJolt'); + success = false; + if (cleanSet) { + reset(); + GJUtil.logout(false, true); + } + }); + + if (!success) + return; + + Logs.trace('Global data set successfully!', SUCCESS, LIGHTGRAY, "GameJolt"); + + if (callback != null) callback(); + } + //endregion + + public static function setUserData() + { + + } + + public static function loadUserData() + { + + } + + static function getGJX():Null + { + if (!Paths.assetsTree.existsSpecific(xmlPath, "TEXT", AssetSource.MODS)) + return null; + + var access:Access = null; + try { + access = new Access(Xml.parse(Paths.assetsTree.getSpecificAsset(xmlPath, "TEXT", AssetSource.MODS)).firstElement()); + } catch(e) { + Logs.trace('Error while parsing gamejolt.xml: ${Std.string(e)}', ERROR, LIGHTGRAY, 'GameJolt'); + } + return access; + } + + static function encryptToken(token:String) + { + var validHex:String = "0123456789abcdef"; + + var dateString:String = Date.now().toString(); + + var hexString:String = ''; + + for (i in 0...dateString.length - 1) { + if (dateString.charAt(i) == ' ') continue; + if (hexString.length == 32) break; + var charCode:Int = StringTools.fastCodeAt(dateString, i); + var charString:String = StringTools.hex(charCode); + hexString += charString.substr(0, Std.int(Math.min(charString.length, 32 - hexString.length))); + } + + for (i in 0...(32 - hexString.length)) { + if (FlxG.random.bool()) { + hexString += validHex.charAt(FlxG.random.int(0, validHex.length - 1)); + } else { + hexString = validHex.charAt(FlxG.random.int(0, validHex.length - 1)) + hexString; + } + } + var iv:Bytes = Bytes.ofHex(hexString); + + @:privateAccess + var aes:Aes = new Aes(Bytes.ofHex(GameJoltSecurity.CODENAME_AES_KEY), iv); + var encryp:Bytes = aes.encrypt(Mode.OFB, Bytes.ofString(token), Padding.NoPadding); + + GameJoltSecurity.encryptedGameToken = Flags.MOD_GAMEJOLT_ENCRYPTED_TOKEN = hexString.toLowerCase() + encryp.toHex(); + } + + public static function reset(fullWipe:Bool = false) + { + earnedTrophies = []; + if (fullWipe) { + ownerUsername = null; + ownerUserId = null; + leaderboards = []; + definedTrophies = []; + customTrophies = []; + dataToInclude = []; + freshStart = false; + } + } + #else + public static function loadAdminData() + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + return; + } + + public static function loadGlobalData() + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + return; + } + + public static function setGlobalData(cleanSet:Bool = false, ?callback:Void->Void, ?data:Access) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + return; + } + + public static function setUserData() + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + return; + } + + public static function loadUserData() + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + return; + } + + public static function reset(fullWipe:Bool = false) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + return; + } + #end +} \ No newline at end of file diff --git a/source/funkin/backend/utils/GameJoltSecurityPublic.hx b/source/funkin/backend/system/gamejolt/GameJoltSecurity.hx similarity index 78% rename from source/funkin/backend/utils/GameJoltSecurityPublic.hx rename to source/funkin/backend/system/gamejolt/GameJoltSecurity.hx index 3a51d6f1f2..217e05bccd 100644 --- a/source/funkin/backend/utils/GameJoltSecurityPublic.hx +++ b/source/funkin/backend/system/gamejolt/GameJoltSecurity.hx @@ -1,9 +1,12 @@ -package funkin.backend.utils; +package funkin.backend.system.gamejolt; -import hscript.IHScriptCustomBehaviour; +import hscript.IHScriptCustomAccessBehaviour; import funkin.backend.utils.GJUtil; import funkin.backend.utils.GJUtil.RequestType; -import haxe.crypto.Md5; +import haxe.crypto.*; +import haxe.crypto.mode.Mode; +import haxe.crypto.padding.Padding; +import haxe.io.Bytes; import funkin.backend.utils.GJUtil.*; import haxe.Http; import haxe.Json; @@ -16,31 +19,16 @@ import openfl.events.*; * If the raw game keys are made public, people can mess with leaderboards, data, achievements, * or whatever else is on the game page. * - * As such, Codename Engine requires players to encrypt keys, and the method of - * encryption is non-disclosable for security reasons, so the original file - * used in distributed CNE builds cannot be shared. - * Instead, we provide this public-facing file for hardcoding purposes. - * - * To use this file, make a copy and rename the copy's filename and class name to - * `GameJoltSecurity.hx`. To modify the encryption method, go to the function - * `set_encryptedGameToken` and modify the code in the first half of the null - * check (`if (tok != null) {}`). - * - * **WE HIGHLY ENCOURAGE YOU TO COME UP WITH AN ENCRYPTION METHOD FOR GAMEJOLT KEYS.** - * HaxeFoundation's crypto package is installed with Codename, you can see the methods - * you can use (as well as the documentation) [here](https://github.com/HaxeFoundation/crypto). - * - * # ***DO NOT LET YOUR PLAYERS PUT THE RAW KEYS IN ANY SOFTCODED FILES!!! WE ARE NOT*** - * ***RESPONSIBLE IF YOU DON'T MAKE YOUR PLAYERS ENCRYPT THEIR KEYS AND THEIR STUFF*** - * ***GETS HACKED!!!!*** + * As such, Codename Engine requires players to encrypt keys using the AES protocol, and + * the key to decrypt such keys is non-disclosable (hence why it's in a .env file). * * Also for security purposes, this class is unattainable via HScript. * * ~ SplatterDash */ -@:noCustomClass -@:dox(hide) -abstract class GameJoltSecurityPublic implements IHScriptCustomBehaviour + +@:noCustomClass @:build(funkin.backend.system.macros.SecretMacro.build()) +class GameJoltSecurity implements IHScriptCustomAccessBehaviour { /** * Token for the user if they're logged in. @@ -52,6 +40,17 @@ abstract class GameJoltSecurityPublic implements IHScriptCustomBehaviour */ public static var gameId:String = ''; + /** + * Map of all trophies defined using specific names. + * Specifically stored here so that it's harder to cheese song, + * FC or other specific trophies. + */ + public static var definedTrophies(default, null):Map = []; + + /** + * + */ + public static var definedLeaderboards(default, null):Map = []; /** * The encrypted game token. Set using GAMEJOLT_ENCRYPTED_TOKEN in ini file. */ @@ -60,39 +59,80 @@ abstract class GameJoltSecurityPublic implements IHScriptCustomBehaviour /** * The unencrypted game token. It's insanely hard to get this variable. */ - @:noPrivateAccess static var revealedGameToken:String; + @:noPrivateAccess private static var revealedGameToken:String; /** * URL sent to GameJolt per request. */ - @:noPrivateAccess static var url(get, never):String; + @:noPrivateAccess private static var url(get, never):String; /** * The previous response created by the API client. Usually for just storage purposes. */ - static var lastResponse:Response = {success: false, message: "No response yet."}; + private static var lastResponse:Response = {success: false, message: "No response yet."}; /** * The current call being processed. */ - static var curCall:Null = null; + private static var curCall:Null = null; + + /** + * The secret key to decrypt GameJolt keys using AES. This cannot be traced or located in any way. + */ + @:envField + private static final CODENAME_AES_KEY:Null; // hscript - thanks LJ :D + + //region IHScriptCustomAccessBehaviour implementation + public var __allowSetGet:Bool = false; + public function hget(name:String):Dynamic - { return null; - } public function hset(name:String, val:Dynamic):Dynamic - { return null; - } + + public function __callGetter(name:String):Dynamic + return null; + + public function __callSetter(name:String, val:Dynamic):Dynamic + return null; + //endregion static function get_url():String { return sign('https://api.gamejolt.com/api/game/v1_2${parseType(curCall)}'); } + /** + * This is a copy of GJUtil's "send" request, but kept here since Hscript can't get here. + * Any calls here are guaranteed to be from hardcoding. + * @param call + * @param async + * @param onError + * @param onComplete + * @param onProgress + */ + public static function sendTrusted(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:Response->Void, ?onProgress:Array->Void) + { + @:privateAccess { + if (GJUtil.executing || !GJUtil.active) + return; + GJUtil.executing = true; + } + + var resp:Response = handleRequest(async, call, onProgress); + @:privateAccess + GJUtil.executing = false; + if (resp.message != null && onError != null) + onError(resp.message); + else if (resp.message == null && onComplete != null) { + @:privateAccess + onComplete(GJUtil.formatImages(resp)); + } + } + static function handleRequest(async:Bool = false, data:RequestType, ?onProgress:Array->Void):Response { if (encryptedGameToken == null || gameId == null) { @@ -108,7 +148,10 @@ abstract class GameJoltSecurityPublic implements IHScriptCustomBehaviour loader.addEventListener(Event.COMPLETE, function(complete) { lastResponse = Json.parse(cast(loader.data, String)).response; if (lastResponse.message != null) { - trace('Response Error: ${lastResponse.message}'); + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText('Response Error: ${lastResponse.message}') + ], ERROR); } }); @@ -126,7 +169,10 @@ abstract class GameJoltSecurityPublic implements IHScriptCustomBehaviour loader.onData = function(data) { lastResponse = Json.parse(data).response; if (lastResponse.message != null) - trace('Response Error: ${lastResponse.message}'); + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText('Response Error: ${lastResponse.message}') + ], ERROR); }; loader.onError = function(error) { lastResponse = {success: false, message: 'Request Error: ${error}'}; @@ -320,9 +366,15 @@ abstract class GameJoltSecurityPublic implements IHScriptCustomBehaviour */ static function set_encryptedGameToken(tok:String) { - if (tok != null) { - // Encryption method goes here. - revealedGameToken = tok; + if (tok != null && CODENAME_AES_KEY != null) { + var iv:String = tok.substr(0, 32); + var theK:String = tok.substr(32); + + var aes:Aes = new Aes(Bytes.ofHex(CODENAME_AES_KEY), Bytes.ofHex(iv.toUpperCase())); + + var dat:String = aes.decrypt(Mode.OFB, Bytes.ofHex(theK), Padding.NoPadding).toString(); + + revealedGameToken = dat; } else { revealedGameToken = null; } diff --git a/source/funkin/backend/system/macros/SecretMacro.hx b/source/funkin/backend/system/macros/SecretMacro.hx new file mode 100644 index 0000000000..3385db3735 --- /dev/null +++ b/source/funkin/backend/system/macros/SecretMacro.hx @@ -0,0 +1,104 @@ +package funkin.backend.system.macros; + +#if macro +import sys.io.File; +import sys.FileSystem; +import Sys; +import haxe.macro.Expr; +import haxe.macro.Expr.Field; +import haxe.macro.Context; +#end + +@:dox(hide) class SecretMacro { + static var envPath:String = '.env'; + // A little bit taken from Funkin's base code. Not too much though. + public static macro function build():Array + { + var contents:Null = !FileSystem.exists(envPath) ? buildEnv() : File.getContent(envPath); + + if (contents == null) + contents = buildEnv(); + + final indivVars:Array = contents.split('\n'); + + final envMap:Map = []; + + for (varE in indivVars) { + // If there's no equal sign, or multiple equal signs, we don't process this variable due to errors. + if (varE.indexOf('=') == -1 || varE.indexOf('=') != varE.lastIndexOf('=')) + continue; + + var titAndVal:Array = varE.split('='); + envMap.set(titAndVal[0], titAndVal[1]); + } + + // Now we look at the actual fields to try and match things up. + final buildFields:Array = Context.getBuildFields(); + + for (fld in buildFields) { + if (fld.access.contains(AStatic)) switch(fld.kind) { + case FVar(t, e): + for (meta in fld.meta) { + if (meta.name != ':envField') + continue; + + // Not gonna do the 'mandatoryIfDefined' stuff because that's not necessary! + // But because we're not fixing what isn't broken, time for null string checks. + var isNullString:Bool = false; + switch (t) { + case TPath(tp): + if (tp.name == 'Null' && tp.params != null && tp.params.length == 1) + switch (tp.params[0]) { + case TPType(TPath(tptp)): + if (tptp.name == 'String') + { + isNullString = true; + } + case _: + } + case _: + } + + if (!isNullString) + Context.fatalError('Field ${fld.name} must be of type Null to use :envField', fld.pos) + else { + if (envMap.exists(fld.name)) + buildFields[buildFields.indexOf(fld)].kind = FVar(t, macro $v{envMap.get(fld.name)}); + else + Sys.println('WARNING: Value for environment variable "${fld.name}" not found.'); + } + } + case _: + // nothing lol + } + } + return buildFields; + } + + private static function buildEnv():Null + { + if (sys.FileSystem.exists(envPath)) + return sys.io.File.getContent(envPath); + + // For future peoples: to append an item to .env, just add it as a new item in this array. + // Use the format 'NAME_ALL_CAPS=value' - like an ini file! + var items:Array = ['CODENAME_AES_KEY=${generateKey()}']; + var finalProd:String = items.join('\n'); + + sys.io.File.saveContent(Sys.getCwd() + '\\.env', finalProd); + + return finalProd; + } + + private static function generateKey():String + { + var validChars:String = "0123456789ABCDEF"; + var outputStr:String = ''; + + for (i in 0...64) + { + outputStr += validChars.charAt(Math.round(Math.random() * 15)); + } + return outputStr; + } +} \ No newline at end of file diff --git a/source/funkin/backend/utils/GJUtil.hx b/source/funkin/backend/utils/GJUtil.hx index cc8ee81e37..e05569ed9a 100644 --- a/source/funkin/backend/utils/GJUtil.hx +++ b/source/funkin/backend/utils/GJUtil.hx @@ -1,5 +1,8 @@ package funkin.backend.utils; +import funkin.backend.system.gamejolt.GameJoltData; +import funkin.backend.system.gamejolt.GameJoltSecurity; + /** * This is how GameJolt API responses are formatted like. */ @@ -94,7 +97,7 @@ typedef Trophy = { * The way the user data is fetched from the GameJolt API. * * @param id The ID of the User. - * @param type The cathegory the User is cataloged like in GameJolt. + * @param type The category the User is cataloged like in GameJolt. * @param username The username of the User. (Also available for guests). * @param avatar_url The link of the avatar of the User. * @param signed_up A short description about how long the User have been in GameJolt. @@ -183,6 +186,11 @@ class GJUtil */ public static var userName(default, set):String; + /** + * The avatar of the logged in user. + */ + public static var userAvatarUrl(default, null):String; + /** * Whether or not the GameJolt utility is operational. * This cannot be set other than load operations. @@ -190,14 +198,36 @@ class GJUtil public static var active(default, null):Bool = false; /** - * Whether or not the utility is executing a call. + * Helper function in case the session is lost in the middle of the game. */ - static var executing:Bool = false; + public static var onLostSession:NullVoid> = null; /** - * Helper function in case the session is lost in the middle of the game. + * Helper function that runs when an achievement is unlocked. + * Can be useful for notifications. */ - public static var onLostSession:NullVoid> = null; + public static var onTrophyUnlock:Null->Void> = null; + + /** + * Whether or not the utility is executing a call. + */ + static var executing:Bool = false; + + #if GAMEJOLT_API + public static function init() + { + if (Flags.MOD_GAMEJOLT_GAME_ID == '') + return; + + if (Flags.MOD_GAMEJOLT_ENCRYPTED_TOKEN == '') { + GameJoltData.loadAdminData(); + if (Flags.MOD_GAMEJOLT_ENCRYPTED_TOKEN == '') + return; + } else if (FlxG.save.data.gameJoltArray != null) { + var gjDat:Array = FlxG.save.data.gameJoltArray; + GJUtil.attemptLogin(gjDat[0], gjDat[1]); + } + } /** * Helper function to simplify the login process. @@ -205,9 +235,9 @@ class GJUtil * @param token User token of user attempting to login. * @return Bool Whether the attempt was successfull or not. */ - public static function attemptLogin(name:String, token:String):Bool + public static function attemptLogin(name:String, token:String, checkCreds:Bool = false, tempLogin:Bool = false):Bool { - if(Flags.MOD_GAMEJOLT_GAME_ID != '' && Flags.MOD_GAMEJOLT_TOKEN != '') + if(Flags.MOD_GAMEJOLT_GAME_ID != '' && Flags.MOD_GAMEJOLT_ENCRYPTED_TOKEN != '') active = true else return false; @@ -215,27 +245,88 @@ class GJUtil var ret:Bool = false; userName = name; GameJoltSecurity.user_token = token; - send(RequestType.SESSION_OPEN, false, function(err) { + var batchCalls:Array = [RequestType.SESSION_OPEN]; + if (checkCreds) batchCalls.unshift(RequestType.USER_AUTH); + send(RequestType.BATCH(true, false, batchCalls), false, function(err) { userName = null; GameJoltSecurity.user_token = null; }, function(resp) { - trace('GameJolt logged in as ${userName}'); ret = true; - openfl.Lib.application.onExit.add(onExitApp); - FlxG.signals.postUpdate.add(pingTimer); - FlxG.save.data.gameJoltArray = [userName, token]; - FlxG.save.flush(); + if (!tempLogin) { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("Successfully logged in user "), + Logs.logText(userName, GREEN), + Logs.logText('!') + ], SUCCESS); + openfl.Lib.application.onExit.add(onExitApp); + FlxG.signals.postUpdate.add(pingTimer); + if (checkCreds) { + FlxG.save.data.gameJoltArray = [userName, token]; + FlxG.save.flush(); + } + } }); return ret; } - static function onExitApp(i:Int) + public static function logout(wipeSave:Bool = false, tempLogin:Bool = false) { - logout(); + if (!active) + return; + if (!tempLogin) + shutdownFunctions(); + send(RequestType.SESSION_CLOSE, true, null, function(resp) { + if (!tempLogin) + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("User "), + Logs.logText(userName, GREEN), + Logs.logText(' logged out successfully.') + ], VERBOSE); + userName = null; + GameJoltSecurity.user_token = null; + if (wipeSave) { + FlxG.save.data.gameJoltArray = null; + FlxG.save.flush(); + } + }); + } + + public static function makeCall(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:Response->Void, ?onProgress:Array->Void) + { + switch(call) { + case BATCH(parallel, breakOnError, requests): + return; + + case DATA_GETKEYS(fromUser, pattern): + return; + + case DATA_REMOVE(key, fromUser): + return; + + case DATA_SET(key, data, toUser): + return; + + case DATA_UPDATE(key, operation, toUser): + return; + + case USER_AUTH: + return; + + case SCORES_ADD(score, sort, extra_data, table_id): + return; + + case TROPHIES_ADD(trophy_id): + return; + + case _: + send(call, async, onError, onComplete, onProgress); + } } static var pingTime:Int = 0; - public static function pingTimer() + static function pingTimer() { pingTime += 1; if (pingTime < 10000) return; @@ -243,37 +334,35 @@ class GJUtil pingSession(); } - public static function pingSession() + static function pingSession() { send(RequestType.SESSION_PING(true), true, (str) -> { - trace('GameJolt session lost.'); if (onLostSession != null) onLostSession(); shutdownFunctions(); + Logs.trace("Session lost; GJUtil shut down successfully.", WARNING, LIGHTGRAY, 'GameJolt'); active = false; }); } - public static function logout() + static function onExitApp(i:Int) { - if (!active) - return; - - shutdownFunctions(); - send(RequestType.SESSION_CLOSE, false, null, function(resp) { - trace('GameJolt account ${userName} logged out successfully.'); - userName = null; - GameJoltSecurity.user_token = null; - }); + logout(); } static function shutdownFunctions() { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("Logging out user "), + Logs.logText(userName, GREEN), + Logs.logText('...') + ], INFO); FlxG.signals.postUpdate.remove(pingTimer); openfl.Lib.application.onExit.remove(onExitApp); onLostSession = null; } - public static function send(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:Response->Void, ?onProgress:Array->Void) + @:noPrivateAccess static function send(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:Response->Void, ?onProgress:Array->Void) { if (executing || !active) return; @@ -318,4 +407,7 @@ class GJUtil loggedIn = (name != null && name != ''); return userName = name; } + #else + + #end } \ No newline at end of file diff --git a/source/funkin/menus/MainMenuState.hx b/source/funkin/menus/MainMenuState.hx index 43f33ebb53..ae44f4b5ce 100644 --- a/source/funkin/menus/MainMenuState.hx +++ b/source/funkin/menus/MainMenuState.hx @@ -7,6 +7,7 @@ import funkin.backend.FunkinText; import funkin.backend.scripting.events.menu.MenuChangeEvent; import funkin.backend.scripting.events.NameEvent; import funkin.menus.credits.CreditsMain; +import funkin.menus.gamejolt.GameJoltMenu; import funkin.options.OptionsMenu; import lime.app.Application; @@ -62,6 +63,8 @@ class MainMenuState extends MusicBeatState for (i=>option in optionShit) { + if (option == 'gamejolt') + continue; var menuItem:FlxSprite = new FlxSprite(0, 60 + (i * 160)); menuItem.frames = Paths.getFrames('menus/mainmenu/${option}'); menuItem.animation.addByPrefix('idle', option + " basic", 24); @@ -74,6 +77,17 @@ class MainMenuState extends MusicBeatState menuItem.antialiasing = true; } + var menuItem:FlxSprite = new FlxSprite(FlxG.width - 138, FlxG.height - 138).loadGraphic(Paths.image('menus/gamejolt-icon')); + menuItem.setGraphicSize(128); + menuItem.updateHitbox(); + menuItem.animation.add('idle', [0], 1, false); + menuItem.animation.add('selected', [0], 1, false); + menuItem.animation.play('idle'); + menuItem.ID = menuItems.length; + menuItems.add(menuItem); + menuItem.scrollFactor.set(); + menuItem.antialiasing = true; + FlxG.camera.follow(camFollow, null, 0.06); versionText = new FunkinText(5, FlxG.height - 2, 0, [ @@ -190,6 +204,7 @@ class MainMenuState extends MusicBeatState case 'freeplay': FlxG.switchState(new FreeplayState()); case 'donate', 'credits': FlxG.switchState(new CreditsMain()); // kept donate for not breaking scripts, if you don't want donate to bring you to the credits menu, thats easy softcodable - Nex case 'options': FlxG.switchState(new OptionsMenu()); + case 'gamejolt': FlxG.switchState(new GameJoltMenu()); } }); } diff --git a/source/funkin/menus/gamejolt/GameJoltCompleteScreen.hx b/source/funkin/menus/gamejolt/GameJoltCompleteScreen.hx new file mode 100644 index 0000000000..554ec816ac --- /dev/null +++ b/source/funkin/menus/gamejolt/GameJoltCompleteScreen.hx @@ -0,0 +1,57 @@ +package funkin.menus.gamejolt; + +import funkin.editors.ui.UIState; +import funkin.editors.ui.UIButton; +import funkin.editors.ui.UIText; +import openfl.desktop.Clipboard; +import flixel.addons.display.FlxBackdrop; +import funkin.backend.system.gamejolt.GameJoltData; +import funkin.menus.TitleState; +import funkin.backend.system.Controls; + +class GameJoltCompleteScreen extends UIState +{ + var mainText:UIText; + var copyText:UIText; + var copyButton:UIButton; + var continueText:UIText; + + override public function create() + { + super.create(); + + add(mainText = new UIText(0, 50, FlxG.width, + 'Hey there funkhead! + Your GameJolt data was recognized and registered to your game\'s data store successfully. + From here on, only ${GameJoltData.ownerUsername} will be able to change the global data. As for the XML - for security purposes, we took out the login info and game token. Feel free to discard the XML entirely, or keep it as a souvenir - your globals load from your game\'s data store now! + Before you go: it\'s important to press the "Copy" button below and copy the following text into your mod\'s modpack.ini file. This is your game\'s security key encrypted uniquely for this build. Paste it in the "Common" section.', + 30)); + + mainText.alignment = CENTER; + mainText.antialiasing = true; + + add(copyText = new UIText(0, mainText.height + 60, FlxG.width, 'GAMEJOLT_ENCRYPTED_TOKEN=\'${Flags.MOD_GAMEJOLT_ENCRYPTED_TOKEN}\'', 16)); + copyText.alignment = CENTER; + copyText.antialiasing = true; + + add(copyButton = new UIButton(0, copyText.y + copyText.height + 10, "Copy", () -> { + Clipboard.generalClipboard.setData(TEXT_FORMAT, copyText.text); + })); + copyButton.color = 0xFF3F3FFF; + copyButton.x = (FlxG.width / 2) - (copyButton.bWidth / 2); + + add(continueText = new UIText(0, copyButton.y + copyButton.bHeight + 30, FlxG.width, '~ Press ${controls.getKeyName(ACCEPT)} to continue ~')); + continueText.alignment = CENTER; + + CoolUtil.playMusic(Paths.music('breakfast')); + } + + override public function update(elapsed:Float) + { + super.update(elapsed); + if (controls.ACCEPT) { + CoolUtil.playMenuSFX(CONFIRM); + FlxG.switchState(new TitleState()); + } + } +} \ No newline at end of file diff --git a/source/funkin/menus/gamejolt/GameJoltLoginSuccess.hx b/source/funkin/menus/gamejolt/GameJoltLoginSuccess.hx new file mode 100644 index 0000000000..5e27a0c4c6 --- /dev/null +++ b/source/funkin/menus/gamejolt/GameJoltLoginSuccess.hx @@ -0,0 +1,23 @@ +package funkin.menus.gamejolt; + +import funkin.editors.ui.UIButton; +import funkin.editors.ui.UISubstateWindow; + +class GameJoltLoginSuccess extends UISubstateWindow +{ + var confirmButton:UIButton; + + public override function create() + { + winTitle = "Login Success!"; + + messageSpr.text = 'You have successfully logged in as ${GJUtil.userName}!'; + + super.create(); + + add(confirmButton = new UIButton(windowSpr.x + (windowSpr.bWidth / 2) - 62, windowSpr.y + windowSpr.bHeight - 48, "Sweet!", function() { + close(); + }, 125)); + confirmButton.color = 0xFF00FF00; + } +} \ No newline at end of file diff --git a/source/funkin/menus/gamejolt/GameJoltLoginWindow.hx b/source/funkin/menus/gamejolt/GameJoltLoginWindow.hx new file mode 100644 index 0000000000..84a127cd7d --- /dev/null +++ b/source/funkin/menus/gamejolt/GameJoltLoginWindow.hx @@ -0,0 +1,52 @@ +package funkin.menus.gamejolt; + +import funkin.editors.ui.UISubstateWindow; +import funkin.editors.ui.UIButton; +import funkin.editors.ui.UITextBox; +import funkin.editors.ui.UIText; +import funkin.menus.gamejolt.GameJoltTokenInfoWindow; + +class GameJoltLoginWindow extends UISubstateWindow { + public var usernameBox:UITextBox; + public var userTokenLabel:UIText; + public var userTokenInfo:UIButton; + public var userTokenBox:UITextBox; + + public var closeButton:UIButton; + public var loginButton:UIButton; + + public var daX:Float; + + public override function create() { + //TODO: get translations for text + winTitle = "Login with GameJolt..."; + + winWidth = 350; + winHeight = 350; + + super.create(); + + daX = windowSpr.x + 20; + + add(usernameBox = new UITextBox(daX, windowSpr.y + 60, "")); + usernameBox.members.push(new UIText(daX, usernameBox.y - 24, 0, "Username")); + + add(userTokenBox = new UITextBox(daX, usernameBox.y + usernameBox.height + 60, "")); + userTokenBox.members.push(userTokenLabel = new UIText(daX, userTokenBox.y - 24, 0, "User Token")); + userTokenBox.members.push(userTokenInfo = new UIButton(daX + userTokenLabel.width, userTokenLabel.y, "?", () -> { FlxG.state.openSubState(new GameJoltTokenInfoWindow()); }, 24, 24)); + userTokenInfo.color = 0xFF3737FF; + userTokenBox.label.textField.displayAsPassword = true; + + add(loginButton = new UIButton(windowSpr.x + windowSpr.bWidth - 20 - 125, windowSpr.y + windowSpr.bHeight - 16 - 32, "Login", function() { + if (GJUtil.attemptLogin(usernameBox.label.text, userTokenBox.label.text, true)) { + FlxG.state.openSubState(new GameJoltLoginSuccess()); + close(); + } else { + + } + }, 125)); + + add(closeButton = new UIButton(loginButton.x - 20 - loginButton.bWidth, loginButton.y, TU.translate("editor.cancel"), close, 125)); + closeButton.color = 0xFFFF0000; + } +} \ No newline at end of file diff --git a/source/funkin/menus/gamejolt/GameJoltMenu.hx b/source/funkin/menus/gamejolt/GameJoltMenu.hx new file mode 100644 index 0000000000..0969b2dc08 --- /dev/null +++ b/source/funkin/menus/gamejolt/GameJoltMenu.hx @@ -0,0 +1,47 @@ +package funkin.menus.gamejolt; + +import funkin.editors.ui.UIState; +import funkin.editors.ui.UIButton; +import funkin.editors.ui.UIText; +import funkin.menus.gamejolt.GameJoltLoginWindow; + +class GameJoltMenu extends UIState +{ + var mainGroup:FlxTypedSpriteGroup = new FlxTypedSpriteGroup(); + + override function create() + { + super.create(); + + add(new FlxSprite().loadAnimatedGraphic(Paths.image('menus/menuDesat'))); + + createPages(); + } + + function createPages() + { + var lgIn:Bool = GJUtil.loggedIn; + + mainGroup.add(new UIButton((FlxG.width / 2) - 60, FlxG.height - 200, "Login", () -> { + openSubState(new GameJoltLoginWindow()); + })); + + add(mainGroup); + + var daGroup:FlxTypedSpriteGroup = new FlxTypedSpriteGroup(); + + daGroup.add(new UIText(0, 0, FlxG.width, "NOT LOGGED IN")); + + add(daGroup); + } + + override function update(elapsed:Float) + { + super.update(elapsed); + + if (controls.BACK) { + CoolUtil.playMenuSFX(CANCEL, 0.7); + FlxG.switchState(new MainMenuState()); + } + } +} \ No newline at end of file diff --git a/source/funkin/menus/gamejolt/GameJoltTokenInfoWindow.hx b/source/funkin/menus/gamejolt/GameJoltTokenInfoWindow.hx new file mode 100644 index 0000000000..4751dc45d6 --- /dev/null +++ b/source/funkin/menus/gamejolt/GameJoltTokenInfoWindow.hx @@ -0,0 +1,32 @@ +package funkin.menus.gamejolt; + +import funkin.editors.ui.UISprite; +import funkin.editors.ui.UIButton; +import funkin.editors.ui.UIText; +import funkin.editors.ui.UISubstateWindow; + +class GameJoltTokenInfoWindow extends UISubstateWindow { + public var text:UIText; + public var closeButton:UIButton; + + public var daX:Float; + + public override function create() { + //TODO: get translations for text + winTitle = "GameJolt Token Info"; + + winWidth = 756; + winHeight = 220; + + super.create(); + + daX = windowSpr.x + 20; + + add(text = new UIText(daX, windowSpr.y + 46, windowSpr.width - ((daX - windowSpr.x) * 2), "!! THIS IS NOT YOUR GAMEJOLT ACCOUNT PASSWORD !!\n\nTo access your game token, click on your profile icon, then click on \"Game Token\".")); + + //add(new UISprite(daX, text.y + text.height + 10).loadGraphic(Paths.image())) + add(closeButton = new UIButton(windowSpr.x + (windowSpr.bWidth / 2) - 62, windowSpr.y + windowSpr.bHeight - 48, TU.translate("editor.saveClose"), function() { + close(); + }, 125)); + } +} \ No newline at end of file diff --git a/source/hscript/Config.hx b/source/hscript/Config.hx index 7c8e03f105..b1e53488a0 100644 --- a/source/hscript/Config.hx +++ b/source/hscript/Config.hx @@ -29,7 +29,7 @@ class Config { // Incase any of your files fail // These are the module names public static final DISALLOW_CUSTOM_CLASSES = [ - "funkin.backend.utils.GameJoltSecurity", // don't want people getting those gamejolt keys! + ]; public static final DISALLOW_ABSTRACT_AND_ENUM = [ @@ -38,6 +38,7 @@ class Config { @:unreflective public static final IMPORT_BLACKLIST:Array = [ - + "funkin.backend.system.gamejolt.GameJoltSecurity", // don't want people getting those gamejolt keys! + "funkin.backend.system.gamejolt.GameJoltData", // Global data items and really bad things to modify ]; } From 6f2833a5997f3fa9bfead9a315fe49abffd52f8f Mon Sep 17 00:00:00 2001 From: SplatterDash <95732227+SplatterDash@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:04:46 -0400 Subject: [PATCH 4/5] checkpoint commit 2 most of UI done - need achievement/leaderboard loading and implementation of leaderboard and achievement unlocking to complete commit --- project.xml | 2 +- source/funkin/backend/system/MainState.hx | 3 +- .../backend/system/gamejolt/GameJoltData.hx | 442 ++++++++++++------ .../system/gamejolt/GameJoltSecurity.hx | 155 +++--- source/funkin/backend/utils/GJUtil.hx | 296 +++++++++--- source/funkin/menus/MainMenuState.hx | 48 +- .../menus/gamejolt/GameJoltCompleteScreen.hx | 20 +- .../gamejolt/GameJoltConfirmationWindow.hx | 53 +++ .../menus/gamejolt/GameJoltDataWindow.hx | 119 +++++ .../menus/gamejolt/GameJoltInfoWindow.hx | 48 ++ .../menus/gamejolt/GameJoltLoginSuccess.hx | 23 - .../menus/gamejolt/GameJoltLoginWindow.hx | 35 +- source/funkin/menus/gamejolt/GameJoltMenu.hx | 187 +++++++- .../menus/gamejolt/GameJoltTokenInfoWindow.hx | 32 -- 14 files changed, 1093 insertions(+), 370 deletions(-) create mode 100644 source/funkin/menus/gamejolt/GameJoltConfirmationWindow.hx create mode 100644 source/funkin/menus/gamejolt/GameJoltDataWindow.hx create mode 100644 source/funkin/menus/gamejolt/GameJoltInfoWindow.hx delete mode 100644 source/funkin/menus/gamejolt/GameJoltLoginSuccess.hx delete mode 100644 source/funkin/menus/gamejolt/GameJoltTokenInfoWindow.hx diff --git a/project.xml b/project.xml index 45a14243cd..8c8cf40a62 100644 --- a/project.xml +++ b/project.xml @@ -67,7 +67,7 @@ - + diff --git a/source/funkin/backend/system/MainState.hx b/source/funkin/backend/system/MainState.hx index 5d920352b3..8ea5c15ae1 100644 --- a/source/funkin/backend/system/MainState.hx +++ b/source/funkin/backend/system/MainState.hx @@ -10,6 +10,7 @@ import funkin.backend.assets.ModsFolderLibrary; import funkin.backend.assets.ZipFolderLibrary; import funkin.backend.chart.EventsData; import funkin.backend.system.framerate.Framerate; +import funkin.backend.system.gamejolt.GameJoltData; import funkin.backend.utils.GJUtil; import funkin.editors.ModConfigWarning; import funkin.menus.TitleState; @@ -168,7 +169,7 @@ class MainState extends FlxState { GJUtil.init(); #end - var startState:Class = /**GameJoltData.freshStart ?**/ GameJoltCompleteScreen /**: (Flags.DISABLE_WARNING_SCREEN ? TitleState : funkin.menus.WarningState)**/; + var startState:Class = GameJoltData.freshStart ? GameJoltCompleteScreen : (Flags.DISABLE_WARNING_SCREEN ? TitleState : funkin.menus.WarningState); // In this case if the mod we just loaded a compressed modpack, we can't edit or modify files without decompressing it. if (Options.devMode && Options.allowConfigWarning && !isZipMod) { diff --git a/source/funkin/backend/system/gamejolt/GameJoltData.hx b/source/funkin/backend/system/gamejolt/GameJoltData.hx index 7c87d8a3db..4bb63e7670 100644 --- a/source/funkin/backend/system/gamejolt/GameJoltData.hx +++ b/source/funkin/backend/system/gamejolt/GameJoltData.hx @@ -1,5 +1,6 @@ package funkin.backend.system.gamejolt; +import flixel.util.FlxSave; import haxe.xml.Access; import haxe.io.Bytes; import haxe.crypto.Aes; @@ -7,15 +8,15 @@ import haxe.crypto.mode.Mode; import haxe.crypto.padding.Padding; import haxe.Json; import sys.io.File; -import sys.FileSystem; import flixel.input.keyboard.FlxKey; import funkin.backend.utils.GJUtil; import funkin.backend.utils.GJUtil.ScoreTable; import funkin.backend.system.Controls.Control; import funkin.backend.system.gamejolt.GameJoltSecurity; import funkin.backend.assets.AssetSource; +import funkin.savedata.FunkinSave; - +//region Typedefs typedef CNEGameJoltData = { ownerU:String, ownerI:Int, @@ -26,22 +27,23 @@ typedef CNEGameJoltData = { } typedef GameJoltUserData = { - controls:Map>, - options:Map, + options:Dynamic, scores:Dynamic, - misc:Map + ?miscItems:Dynamic, } typedef GJTrophyData = { id:Int, - ?require:Int, + ?require:Array, ?except:Array, ?hidden:Bool, ?weekName:String, } +//endregion class GameJoltData { + //region Variables public static var freshStart(default, null):Bool = false; public static var ownerUsername:Null = null; @@ -66,26 +68,26 @@ class GameJoltData static var xmlPath:String = Paths.xml('config/gamejolt'); - static var helpText:String = ''; +your token, and inject the data into the global data store.'; + //endregion #if GAMEJOLT_API public static function loadAdminData() { - Logs.trace(Paths.assetsTree.getSpecificAsset(xmlPath, "TEXT", AssetSource.MODS)); // get xml var access = getGJX(); @@ -107,57 +109,39 @@ your token, and inject the data into the global data store. -->'; encryptToken(access.node.token.innerData); // - if (!GJUtil.attemptLogin(access.node.owner.att.username, access.node.owner.att.token, true, true)) { - Logs.traceColored([ - Logs.getPrefix("GameJolt"), - Logs.logText("Unable to log in user "), - Logs.logText(access.node.owner.att.username, GREEN), - Logs.logText(' for data upload.') - ], ERROR); - return; - } - - ownerUsername = access.node.owner.att.username; - GameJoltSecurity.sendTrusted(USER_FETCH(ownerUsername), false, function(err) { - Logs.traceColored([ - Logs.getPrefix("GameJolt"), - Logs.logText("Unable to obtain mandatory user ID data from user "), - Logs.logText(ownerUsername, GREEN), - Logs.logText(' for data upload: ${err}') - ], ERROR); - ownerUsername = null; - GJUtil.logout(false, true); - }, function(resp) { - ownerUserId = resp.users[0].id; - }); - - if (ownerUserId == null) - return; - - setGlobalData(true, () -> { - GJUtil.logout(false, true); - var overwriteXml:Xml = access.x; - for (own in access.x.elements()) { - if (own.nodeName == "owner" || own.nodeName == "token") - overwriteXml.removeChild(own); - } + GJUtil.attemptLogin(access.node.owner.att.username, access.node.owner.att.token, (bl) -> { + if (!bl) { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("Unable to log in user "), + Logs.logText(access.node.owner.att.username, GREEN), + Logs.logText(' for data upload.') + ], ERROR); + } else { + ownerUsername = access.node.owner.att.username; + if(GameJoltSecurity.userId == null) { + ownerUsername = null; + GJUtil.logout(false, true); + return; + } - try { - // FileSystem.deleteFile(Paths.assetsTree.getSpecificPath(xmlPath, AssetSource.MODS)); - File.saveContent(Paths.assetsTree.getSpecificPath(xmlPath, AssetSource.MODS), helpText + '\n' + overwriteXml.toString()); - } catch(e) { - Logs.trace('Error creating new XML file: ${e}', ERROR, LIGHTGRAY, "GameJolt"); + setGlobalData(true, (bl) -> { + GJUtil.logout(false, true); + if (bl) + if (buildGamejoltXml()) + freshStart = true; + }, access); } - freshStart = true; - }, access); + }, true, true); } - public static function loadGlobalData():Bool + //region CNE Globals + public static function loadGlobalData(?callback:Bool->Void) { - var ret:Bool = false; GameJoltSecurity.sendTrusted(DATA_FETCH('CNE_DATASTORE', false), true, function(err) { - Logs.trace('Unable to obtain global mod data. GameJolt API turning off automatically.', ERROR, LIGHTGRAY, 'GameJolt'); + Logs.trace('Unable to obtain global mod data: $err GameJolt API turning off automatically.', ERROR, LIGHTGRAY, 'GameJolt'); GJUtil.logout(false, true); + if (callback != null) callback(false); }, function(resp) { var daDat:CNEGameJoltData = cast Json.parse(resp.data); ownerUsername = daDat.ownerU; @@ -166,13 +150,11 @@ your token, and inject the data into the global data store. -->'; customTrophies = daDat.cusTrophies; leaderboards = daDat.leaderboards; dataToInclude = daDat.addlData; - ret = true; + if (callback != null) callback(true); }); - return ret; } - //region Set CNE Glob'ls - public static function setGlobalData(cleanSet:Bool = false, ?callback:Void->Void, ?data:Access) + public static function setGlobalData(cleanSet:Bool = false, ?callback:Bool->Void, ?data:Access) { if (data == null) { data = getGJX(); @@ -192,15 +174,16 @@ your token, and inject the data into the global data store. -->'; // With all of these nodes, it's important to make sure that // what we're importing a) exists, and b) isn't just a blank string. - for (trophyDef in data.node.trophies.nodes.defined) { + if (data.hasNode.trophies) { + if (data.node.trophies.hasNode.defined) for (trophyDef in data.node.trophies.nodes.defined) { if (!trophyDef.has.def || !trophyDef.has.id || trophyDef.att.def == '' || trophyDef.att.id == '') continue; if (!definitions.contains(trophyDef.att.def)) customTrophies.set(trophyDef.att.def, { id: Std.parseInt(trophyDef.att.id), - require: (trophyDef.has.require && trophyDef.att.require != '') ? Std.parseInt(trophyDef.att.require) : null, - except: (trophyDef.has.except && trophyDef.att.except != '') ? trophyDef.att.except.split('//') : null, + require: (trophyDef.has.require && trophyDef.att.require != '') ? [for (trop in trophyDef.att.require.split(',')) Std.parseInt(trop.trim())] : null, + except: (trophyDef.has.except && trophyDef.att.except != '') ? [for (trop in trophyDef.att.except.split('//')) trop.trim()] : null, hidden: (trophyDef.has.hidden && trophyDef.att.hidden != '') ? (trophyDef.att.hidden == 'true') : null, weekName: null, }); @@ -210,81 +193,274 @@ your token, and inject the data into the global data store. -->'; definedTrophies.set(trophyDef.att.def + (trophyDef.att.def == 'week' ? '-${trophyDef.att.weekName}': ''), { id: Std.parseInt(trophyDef.att.id), - require: (trophyDef.has.require && trophyDef.att.require != '') ? Std.parseInt(trophyDef.att.require) : null, - except: (trophyDef.has.except && trophyDef.att.except != '') ? trophyDef.att.except.split('//') : null, + require: (trophyDef.has.require && trophyDef.att.require != '') ? [for (trop in trophyDef.att.require.split(',')) Std.parseInt(trop.trim())] : null, + except: (trophyDef.has.except && trophyDef.att.except != '') ? [for (trop in trophyDef.att.except.split('//')) trop.trim()] : null, hidden: (trophyDef.has.hidden && trophyDef.att.hidden != '') ? (trophyDef.att.hidden == 'true') : null, weekName: (trophyDef.att.def == 'week' && trophyDef.has.weekName && trophyDef.att.weekName != '') ? trophyDef.att.weekName : null, }); } - for (trophyDef in data.node.trophies.nodes.custom) { + if (data.node.trophies.hasNode.custom) for (trophyDef in data.node.trophies.nodes.custom) { if (!trophyDef.has.def || !trophyDef.has.id) continue; customTrophies.set(trophyDef.att.def, { id: Std.parseInt(trophyDef.att.def), - require: (trophyDef.has.require && trophyDef.att.require != '') ? Std.parseInt(trophyDef.att.require) : null, - except: (trophyDef.has.except && trophyDef.att.except != '') ? trophyDef.att.except.split('//') : null, + require: (trophyDef.has.require && trophyDef.att.require != '') ? [for (trop in trophyDef.att.require.split(',')) Std.parseInt(trop.trim())] : null, + except: (trophyDef.has.except && trophyDef.att.except != '') ? [for (trop in trophyDef.att.except.split('//')) trop.trim()] : null, hidden: (trophyDef.has.hidden && trophyDef.att.hidden != '') ? (trophyDef.att.hidden == 'true') : null, weekName: null, }); } + } - for (leaderboard in data.node.leaderboards.nodes.song) { - if (!leaderboard.has.id || !leaderboard.has.name || leaderboard.att.name == '' || leaderboard.att.id == '') - continue; + if (data.hasNode.leaderboards) for (leaderboard in data.node.leaderboards.nodes.song) { + if (!leaderboard.has.id || !leaderboard.has.name || leaderboard.att.name == '' || leaderboard.att.id == '') + continue; - leaderboards.set(leaderboard.att.name + (leaderboard.has.diff ? '-${leaderboard.att.diff}' : '') + ' (${leaderboard.has.vari ? leaderboard.att.vari : 'Default'})', Std.parseInt(leaderboard.att.id)); - } + leaderboards.set(leaderboard.att.name + (leaderboard.has.diff ? '--D:${leaderboard.att.diff}' : '') + ' (V:${leaderboard.has.vari ? leaderboard.att.vari : 'Default'})', Std.parseInt(leaderboard.att.id)); + } - for (dat in data.node.data.nodes.value) { - if (!dat.has.name || dat.att.name == '') - continue; + if (data.hasNode.data) for (dat in data.node.data.nodes.value) { + if (!dat.has.name || dat.att.name == '') + continue; + + var location:String = (dat.has.inSave && dat.att.inSave != '') ? dat.att.inSave : "FlxG"; + var curVars:Array = dataToInclude.exists(location) ? dataToInclude.get(location) : []; + curVars.push(dat.att.name); + dataToInclude.set(location, curVars); + } + + var sendOut:CNEGameJoltData = { + ownerU: ownerUsername, + ownerI: ownerUserId, + defTrophies: definedTrophies, + cusTrophies: customTrophies, + leaderboards: leaderboards, + addlData: dataToInclude, + }; + + Logs.trace('Sending global data...', INFO, LIGHTGRAY, "GameJolt"); + + GameJoltSecurity.sendTrusted(DATA_SET('CNE_DATASTORE', Json.stringify(sendOut), false), true, function(err) { + Logs.trace('Unable to upload global GameJolt data: ${err}', ERROR, LIGHTGRAY, 'GameJolt'); + if (cleanSet) { + reset(); + GJUtil.logout(false, true); + } else if (callback != null) + callback(false); + }, function(resp) { + Logs.trace('Global data set successfully!', SUCCESS, LIGHTGRAY, "GameJolt"); + if (callback != null) callback(true); + }); + } + + public static function wipeGlobalData(?callback:Bool->Void) + { + GameJoltSecurity.sendTrusted(DATA_REMOVE('CNE_DATASTORE', false), true, function(err) { + Logs.trace('Unable to wipe global mod data: $err', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(false); + }, function(resp) { + if (callback != null) callback(true); + }); + } + //endregion - var location:String = (dat.has.inSave && dat.att.inSave != '') ? dat.att.inSave : "FlxG"; - var curVars:Array = dataToInclude.exists(location) ? dataToInclude.get(location) : []; - curVars.push(dat.att.name); - dataToInclude.set(location, curVars); + //region User Data + public static function setUserData(?callback:Bool->Void) + { + FunkinSave.flush(); + + var addlStuff:Dynamic = {}; + + if (dataToInclude != null) for (elem in dataToInclude.keys()) { + var daSave:FlxSave = Reflect.field(elem, "save"); + if (daSave == null) + continue; + var addlSaveItems:Dynamic = {}; + for (itm in dataToInclude.get(elem)) { + var itmVal = Reflect.field(daSave.data, itm); + if (itmVal == null) + continue; + Reflect.setField(addlSaveItems, itm, itmVal); } + Reflect.setField(addlStuff, elem, addlSaveItems); + } - var sendOut:CNEGameJoltData = { - ownerU: ownerUsername, - ownerI: ownerUserId, - defTrophies: definedTrophies, - cusTrophies: customTrophies, - leaderboards: leaderboards, - addlData: dataToInclude, - }; - - Logs.trace('Sending global data...', INFO, LIGHTGRAY, "GameJolt"); - - var success:Bool = true; - GameJoltSecurity.sendTrusted(DATA_SET('CNE_DATASTORE', Json.stringify(sendOut), false), false, function(err) { - Logs.trace('Unable to upload global GameJolt data: ${err}', ERROR, LIGHTGRAY, 'GameJolt'); - success = false; - if (cleanSet) { - reset(); - GJUtil.logout(false, true); - } + var dataToSend:GameJoltUserData = { + options: Options.__save.data, + scores: FunkinSave.save.data.highscores, + miscItems: addlStuff != [] ? addlStuff : null, + }; + + + var daId:Null = GameJoltSecurity.userId; + if (daId == null) { + if (callback != null) callback(false); + } else { + Logs.trace('Sending user data...', INFO, LIGHTGRAY, "GameJolt"); + + GameJoltSecurity.sendTrusted(DATA_SET('USER_$daId', Json.stringify(dataToSend), true), true, function(err) { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("Unable to set data for user "), + Logs.logText(GJUtil.userName, GREEN), + Logs.logText(': ${err}') + ], ERROR); + if (callback != null) + callback(false); + }, function(resp) { + Logs.trace('User data sent successfully!', SUCCESS, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(true); }); - - if (!success) - return; + } + } - Logs.trace('Global data set successfully!', SUCCESS, LIGHTGRAY, "GameJolt"); + public static function loadUserData(?callback:Bool->Void) + { + var daId:Null = GameJoltSecurity.userId; + if (daId == null) { + if (callback != null) callback(false); + } else { + Logs.trace('Fetching user data...', INFO, LIGHTGRAY, "GameJolt"); + + GameJoltSecurity.sendTrusted(DATA_FETCH('USER_$daId', true), true, function(err) { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("Unable to set data for user "), + Logs.logText(GJUtil.userName, GREEN), + Logs.logText(': ${err}') + ], ERROR); + if (callback != null) callback(false); + }, function(resp) { + Logs.trace('User data collected successfully; now setting save data to obtained user data...', INFO, LIGHTGRAY, "GameJolt"); + var daData:GameJoltUserData = cast Json.parse(resp.data); + Options.__save.mergeData(daData.options, true); + FunkinSave.save.data.highscores = daData.scores; + if (daData.miscItems != null) { + for (location in Reflect.fields(daData.miscItems)) { + if (location == null) + continue; + var daSve:FlxSave = Reflect.field(location, "save"); + if (daSve == null) + continue; + for (daItm in Reflect.fields(location)) { + Reflect.setField(daSve.data, daItm, Reflect.field(location, daItm)); + } + + daSve.flush(); + } + } + if (callback != null) callback(true); + }); + } + } - if (callback != null) callback(); + public static function wipeUserData(?callback:Bool->Void) + { + var daId:Null = GameJoltSecurity.userId; + if (daId == null) { + if (callback != null) callback(false); + } else { + GameJoltSecurity.sendTrusted(DATA_REMOVE('USER_$daId', true), true, function(err) { + Logs.trace('Unable to wipe user mod data: $err', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(false); + }, function(resp) { + if (callback != null) callback(true); + }); + } } //endregion - public static function setUserData() + public static function reset(fullWipe:Bool = false) { - + earnedTrophies = []; + if (fullWipe) { + ownerUsername = null; + ownerUserId = null; + leaderboards = []; + definedTrophies = []; + customTrophies = []; + dataToInclude = []; + freshStart = false; + } } - public static function loadUserData() + static function buildGamejoltXml(?fromXml:Xml):Bool { + final bodyNode:Xml = Xml.createElement('gamejolt'); + final trophyNodes:Xml = Xml.createElement('trophies'); + final leaderNodes:Xml = Xml.createElement('leaderboards'); + final dataNodes:Xml = Xml.createElement('data'); + final ret:Xml = Xml.createDocument(); + + for (key => trop in definedTrophies) { + var nodeToTrophy:Xml = Xml.createElement('default'); + nodeToTrophy.set('def', key); + if (trop.require != null) + nodeToTrophy.set('require', trop.require.join(',')); + if (trop.except != null) + nodeToTrophy.set('except', trop.except.join(',')); + if (trop.hidden != null) + nodeToTrophy.set('hidden', '${trop.hidden}'); + if (trop.weekName != null) + nodeToTrophy.set('week', trop.weekName); + nodeToTrophy.set('id', '${trop.id}'); + trophyNodes.addChild(nodeToTrophy); + } + + for (key => trop in customTrophies) { + var nodeToTrophy:Xml = Xml.createElement('custom'); + nodeToTrophy.set('def', key); + if (trop.require != null) + nodeToTrophy.set('require', trop.require.join(',')); + if (trop.except != null) + nodeToTrophy.set('except', trop.except.join(',')); + if (trop.hidden != null) + nodeToTrophy.set('hidden', '${trop.hidden}'); + nodeToTrophy.set('id', '${trop.id}'); + trophyNodes.addChild(nodeToTrophy); + } + + for (key => board in leaderboards) { + var nodeToLeader:Xml = Xml.createElement('song'); + var diffInd:Int = key.indexOf('--D:'); + var variInd:Int = key.indexOf('(V:'); + var nameSplice = key.substring(0, diffInd != -1 ? diffInd : variInd).trim(); + nodeToLeader.set('name', nameSplice); + if (diffInd != -1) { + var diffSplice:String = key.substring(diffInd + 4, variInd).trim(); + nodeToLeader.set('diff', diffSplice); + } + var variSplice:String = key.substring(variInd + 3, key.lastIndexOf(')')).trim(); + nodeToLeader.set('vari', variSplice); + nodeToLeader.set('id', '$board'); + leaderNodes.addChild(nodeToLeader); + } + + for (key => loc in dataToInclude) { + for (itm in loc) { + var nodeToData:Xml = Xml.createElement('value'); + nodeToData.set('name', itm); + nodeToData.set('inSave', key); + dataNodes.addChild(nodeToData); + } + } + + bodyNode.addChild(trophyNodes); + bodyNode.addChild(leaderNodes); + bodyNode.addChild(dataNodes); + ret.addChild(bodyNode); + + var finalBool:Bool = true; + try { + File.saveContent(Paths.assetsTree.getSpecificPath(xmlPath, AssetSource.MODS), '\n' + XMLUtil.fixXMLText(ret.toString())); + } catch(e) { + finalBool = false; + Logs.trace('Error creating new XML file: ${e}', ERROR, LIGHTGRAY, "GameJolt"); + } + return finalBool; } static function getGJX():Null @@ -332,49 +508,48 @@ your token, and inject the data into the global data store. -->'; GameJoltSecurity.encryptedGameToken = Flags.MOD_GAMEJOLT_ENCRYPTED_TOKEN = hexString.toLowerCase() + encryp.toHex(); } - - public static function reset(fullWipe:Bool = false) - { - earnedTrophies = []; - if (fullWipe) { - ownerUsername = null; - ownerUserId = null; - leaderboards = []; - definedTrophies = []; - customTrophies = []; - dataToInclude = []; - freshStart = false; - } - } #else + //region GJ API Inaccessible public static function loadAdminData() { Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); return; } - public static function loadGlobalData() + public static function loadGlobalData(?callback:Bool->Void) { Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); - return; + if (callback != null) callback(false); } - public static function setGlobalData(cleanSet:Bool = false, ?callback:Void->Void, ?data:Access) + public static function setGlobalData(cleanSet:Bool = false, ?callback:Bool->Void, ?data:Access) { Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); - return; + if (callback != null) callback(false); } - public static function setUserData() + public static function wipeGlobalData(?callback:Bool->Void) { Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); - return; + if (callback != null) callback(false); } - public static function loadUserData() + public static function setUserData(?callback:Bool->Void) { Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); - return; + if (callback != null) callback(false); + } + + public static function loadUserData(?callback:Bool->Void) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(false); + } + + public static function wipeUserData(?callback:Bool->Void) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(false); } public static function reset(fullWipe:Bool = false) @@ -382,5 +557,6 @@ your token, and inject the data into the global data store. -->'; Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); return; } + //endregion #end } \ No newline at end of file diff --git a/source/funkin/backend/system/gamejolt/GameJoltSecurity.hx b/source/funkin/backend/system/gamejolt/GameJoltSecurity.hx index 217e05bccd..0aa56461be 100644 --- a/source/funkin/backend/system/gamejolt/GameJoltSecurity.hx +++ b/source/funkin/backend/system/gamejolt/GameJoltSecurity.hx @@ -11,6 +11,14 @@ import funkin.backend.utils.GJUtil.*; import haxe.Http; import haxe.Json; import openfl.events.*; +#if ALLOW_MULTITHREADING +import funkin.backend.utils.ThreadUtil; +#end +#if (target.threaded) +import sys.thread.Thread; +#end + + /** * # A BIG MOTHERFUCKING WARNING @@ -41,16 +49,9 @@ class GameJoltSecurity implements IHScriptCustomAccessBehaviour public static var gameId:String = ''; /** - * Map of all trophies defined using specific names. - * Specifically stored here so that it's harder to cheese song, - * FC or other specific trophies. + * ID number for the currently logged in user. */ - public static var definedTrophies(default, null):Map = []; - - /** - * - */ - public static var definedLeaderboards(default, null):Map = []; + public static var userId:Null = null; /** * The encrypted game token. Set using GAMEJOLT_ENCRYPTED_TOKEN in ini file. */ @@ -67,9 +68,9 @@ class GameJoltSecurity implements IHScriptCustomAccessBehaviour @:noPrivateAccess private static var url(get, never):String; /** - * The previous response created by the API client. Usually for just storage purposes. + * The previous GJResponse created by the API client. Usually for just storage purposes. */ - private static var lastResponse:Response = {success: false, message: "No response yet."}; + private static var lastResponse:GJResponse = {success: false, message: "No response yet."}; /** * The current call being processed. @@ -82,6 +83,10 @@ class GameJoltSecurity implements IHScriptCustomAccessBehaviour @:envField private static final CODENAME_AES_KEY:Null; + #if target.threaded + static final mutex = new sys.thread.Mutex(); + #end + // hscript - thanks LJ :D //region IHScriptCustomAccessBehaviour implementation @@ -100,11 +105,7 @@ class GameJoltSecurity implements IHScriptCustomAccessBehaviour return null; //endregion - static function get_url():String - { - return sign('https://api.gamejolt.com/api/game/v1_2${parseType(curCall)}'); - } - + #if GAMEJOLT_API /** * This is a copy of GJUtil's "send" request, but kept here since Hscript can't get here. * Any calls here are guaranteed to be from hardcoding. @@ -114,7 +115,7 @@ class GameJoltSecurity implements IHScriptCustomAccessBehaviour * @param onComplete * @param onProgress */ - public static function sendTrusted(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:Response->Void, ?onProgress:Array->Void) + public static function sendTrusted(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:GJResponse->Void, ?onProgress:Array->Void) { @:privateAccess { if (GJUtil.executing || !GJUtil.active) @@ -122,65 +123,81 @@ class GameJoltSecurity implements IHScriptCustomAccessBehaviour GJUtil.executing = true; } - var resp:Response = handleRequest(async, call, onProgress); - @:privateAccess - GJUtil.executing = false; - if (resp.message != null && onError != null) - onError(resp.message); - else if (resp.message == null && onComplete != null) { + handleRequest(async, call, function(errstr) { @:privateAccess - onComplete(GJUtil.formatImages(resp)); - } + GJUtil.executing = false; + if (onError != null) onError(errstr); + }, function(resp) { + @:privateAccess { + GJUtil.executing = false; + if (onComplete != null) onComplete(GJUtil.formatImages(resp)); + } + }, onProgress); } - static function handleRequest(async:Bool = false, data:RequestType, ?onProgress:Array->Void):Response + static function handleRequest(async:Bool = false, data:RequestType, ?onError:String->Void, ?onComplete:GJResponse->Void, ?onProgress:Array->Void) { if (encryptedGameToken == null || gameId == null) { lastResponse = {success: false, message: 'Missing game token and/or game ID.'}; curCall = null; - return lastResponse; + if (onError != null) onError(lastResponse.message); } curCall = data; if (async) { - var loader = new openfl.net.URLLoader(); - loader.addEventListener(Event.COMPLETE, function(complete) { - lastResponse = Json.parse(cast(loader.data, String)).response; - if (lastResponse.message != null) { - Logs.traceColored([ - Logs.getPrefix("GameJolt"), - Logs.logText('Response Error: ${lastResponse.message}') - ], ERROR); - } - + #if ALLOW_MULTITHREADING ThreadUtil.execAsync#elseif (target.threaded) Thread.create#end (() -> { + var loader = new openfl.net.URLLoader(); + loader.addEventListener(Event.COMPLETE, function(complete) { + lastResponse = Json.parse(cast(loader.data, String)).response; + if (lastResponse.message != null) { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText('Response Error: ${lastResponse.message}') + ], ERROR); + curCall = null; + if (onError != null) onError(lastResponse.message); + } else { + curCall = null; + if (onComplete != null) onComplete(lastResponse); + } + }); + loader.addEventListener(ProgressEvent.PROGRESS, progress -> { if (onProgress != null) onProgress([progress.bytesLoaded, progress.bytesTotal]);}); + loader.addEventListener(IOErrorEvent.IO_ERROR, function(ioError) { + lastResponse = {success: false, message: 'IO Error: ${ioError.text}'}; + curCall = null; + if (onError != null) onError(lastResponse.message); + }); + loader.addEventListener(SecurityErrorEvent.SECURITY_ERROR, (securityError) -> { + lastResponse = {success: false, message: 'Security Error: ${securityError.text}'}; + curCall = null; + if (onError != null) onError(lastResponse.message); + }); + loader.load(new openfl.net.URLRequest(url)); }); - loader.addEventListener(ProgressEvent.PROGRESS, progress -> { if (onProgress != null) onProgress([progress.bytesLoaded, progress.bytesTotal]);}); - loader.addEventListener(IOErrorEvent.IO_ERROR, function(ioError) { - lastResponse = {success: false, message: 'IO Error: ${ioError.text}'}; - }); - loader.addEventListener(SecurityErrorEvent.SECURITY_ERROR, (securityError) -> { - lastResponse = {success: false, message: 'Security Error: ${securityError.text}'}; - }); - loader.load(new openfl.net.URLRequest(url)); - return {success: false, message: "No response yet."}; } else { var loader:Http = new Http(url); loader.onData = function(data) { - lastResponse = Json.parse(data).response; - if (lastResponse.message != null) + lastResponse = cast Json.parse(data).response; + if (lastResponse.message != null) { Logs.traceColored([ Logs.getPrefix("GameJolt"), Logs.logText('Response Error: ${lastResponse.message}') ], ERROR); + curCall = null; + if (onError != null) onError(lastResponse.message); + } else { + curCall = null; + if (onComplete != null) onComplete(lastResponse); + } }; loader.onError = function(error) { lastResponse = {success: false, message: 'Request Error: ${error}'}; + curCall = null; + if (onError != null) onError(lastResponse.message); }; loader.request(false); } - curCall = null; - return lastResponse; } static function parseType(request:RequestType, signed:Bool = false):String { @@ -360,6 +377,16 @@ class GameJoltSecurity implements IHScriptCustomAccessBehaviour return urlSection; } + /** + * Signs a piece of URL with Md5. + * @param daUrl The old URL piece. + * @return The new URL piece. + */ + static function sign(daUrl:String):String { + var urlToEncode:String = daUrl + revealedGameToken; + return '$daUrl&signature=${Md5.encode(urlToEncode)}'; + } + /** * Setter function for encrypted game token. Also sets revealed game token. * @param tok @@ -381,13 +408,25 @@ class GameJoltSecurity implements IHScriptCustomAccessBehaviour return encryptedGameToken = tok; } - /** - * Signs a piece of URL with Md5. - * @param daUrl The old URL piece. - * @return The new URL piece. - */ - static function sign(daUrl:String):String { - var urlToEncode:String = daUrl + revealedGameToken; - return '$daUrl&signature=${Md5.encode(urlToEncode)}'; + static function get_url():String + { + return sign('https://api.gamejolt.com/api/game/v1_2${parseType(curCall)}'); + } + #else + public static function sendTrusted(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:GJResponse->Void, ?onProgress:Array->Void) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + if (onError != null) onError('GameJolt API not set in Project.xml!'); + } + + static function set_encryptedGameToken(tok:String) + { + return encryptedGameToken = tok; + } + + static function get_url():String + { + return null; } + #end } \ No newline at end of file diff --git a/source/funkin/backend/utils/GJUtil.hx b/source/funkin/backend/utils/GJUtil.hx index e05569ed9a..88cf311772 100644 --- a/source/funkin/backend/utils/GJUtil.hx +++ b/source/funkin/backend/utils/GJUtil.hx @@ -1,12 +1,23 @@ package funkin.backend.utils; +import haxe.Timer; +import flixel.graphics.FlxGraphic; +import openfl.display.BitmapData; import funkin.backend.system.gamejolt.GameJoltData; import funkin.backend.system.gamejolt.GameJoltSecurity; +#if ALLOW_MULTITHREADING +import funkin.backend.utils.ThreadUtil; +#end +#if (target.threaded) +import sys.thread.Thread; +import sys.thread.Mutex; +#end + /** - * This is how GameJolt API responses are formatted like. + * This is how GameJolt API responses are formatted. */ -typedef Response = { +typedef GJResponse = { // General success:Bool, ?message:String, @@ -33,7 +44,7 @@ typedef Response = { ?minute:Int, ?second:Int, // Batch Reception - ?responses:Array + ?responses:Array } /** @@ -191,6 +202,11 @@ class GJUtil */ public static var userAvatarUrl(default, null):String; + /** + * The profile markdown description of the logged in user. + */ + public static var userDescription(default, null):String; + /** * Whether or not the GameJolt utility is operational. * This cannot be set other than load operations. @@ -213,6 +229,15 @@ class GJUtil */ static var executing:Bool = false; + /** + * The timer for calling the session ping. Runs every 10 seconds. + */ + static var daTimer:Null = null; + + #if (target.threaded) + static final mutex = new Mutex(); + #end + #if GAMEJOLT_API public static function init() { @@ -223,7 +248,9 @@ class GJUtil GameJoltData.loadAdminData(); if (Flags.MOD_GAMEJOLT_ENCRYPTED_TOKEN == '') return; - } else if (FlxG.save.data.gameJoltArray != null) { + } + + if (FlxG.save.data.gameJoltArray != null) { var gjDat:Array = FlxG.save.data.gameJoltArray; GJUtil.attemptLogin(gjDat[0], gjDat[1]); } @@ -235,39 +262,54 @@ class GJUtil * @param token User token of user attempting to login. * @return Bool Whether the attempt was successfull or not. */ - public static function attemptLogin(name:String, token:String, checkCreds:Bool = false, tempLogin:Bool = false):Bool + public static function attemptLogin(name:String, token:String, ?callback:Bool->Void, checkCreds:Bool = false, tempLogin:Bool = false) { - if(Flags.MOD_GAMEJOLT_GAME_ID != '' && Flags.MOD_GAMEJOLT_ENCRYPTED_TOKEN != '') - active = true - else - return false; - - var ret:Bool = false; - userName = name; - GameJoltSecurity.user_token = token; - var batchCalls:Array = [RequestType.SESSION_OPEN]; - if (checkCreds) batchCalls.unshift(RequestType.USER_AUTH); - send(RequestType.BATCH(true, false, batchCalls), false, function(err) { - userName = null; - GameJoltSecurity.user_token = null; - }, function(resp) { - ret = true; + if(Flags.MOD_GAMEJOLT_GAME_ID != '' && Flags.MOD_GAMEJOLT_ENCRYPTED_TOKEN != '') { + active = true; + + userName = name; + GameJoltSecurity.user_token = token; + var batchCalls:Array = [SESSION_OPEN]; + if (checkCreds) + batchCalls.unshift(USER_AUTH); if (!tempLogin) { - Logs.traceColored([ - Logs.getPrefix("GameJolt"), - Logs.logText("Successfully logged in user "), - Logs.logText(userName, GREEN), - Logs.logText('!') - ], SUCCESS); - openfl.Lib.application.onExit.add(onExitApp); - FlxG.signals.postUpdate.add(pingTimer); - if (checkCreds) { - FlxG.save.data.gameJoltArray = [userName, token]; - FlxG.save.flush(); - } + batchCalls.push(USER_FETCH(name)); + batchCalls.push(TROPHIES_FETCH(true)); } - }); - return ret; + send(RequestType.BATCH(true, false, batchCalls), true, function(err) { + userName = null; + if (callback != null) callback(false); + }, function(resp) { + GameJoltSecurity.userId = resp.responses[checkCreds ? 2 : 1].users[0].id; + if (!tempLogin) { + userAvatarUrl = resp.responses[checkCreds ? 2 : 1].users[0].avatar_url; + userDescription = resp.responses[checkCreds ? 2 : 1].users[0].developer_description; + GameJoltData.loadGlobalData((bl) -> { + if (bl) { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("Successfully logged in user "), + Logs.logText(userName, GREEN), + Logs.logText('!') + ], SUCCESS); + openfl.Lib.application.onExit.add(onExitApp); + daTimer = new Timer(10000); + daTimer.run = pingSession; + if (checkCreds) { + FlxG.save.data.gameJoltArray = [userName, token]; + FlxG.save.flush(); + } + } else { + Logs.trace('Unable to obtain global data. Logging out of GameJolt.', ERROR, LIGHTGRAY, 'GameJolt'); + logout(false, true); // so that it doesn't remove functions that don't exist + } + }); + } + if (userName != null && callback != null) callback(true); + }); + } + else + if (callback != null) callback(false); } public static function logout(wipeSave:Bool = false, tempLogin:Bool = false) @@ -285,7 +327,6 @@ class GJUtil Logs.logText(' logged out successfully.') ], VERBOSE); userName = null; - GameJoltSecurity.user_token = null; if (wipeSave) { FlxG.save.data.gameJoltArray = null; FlxG.save.flush(); @@ -293,7 +334,16 @@ class GJUtil }); } - public static function makeCall(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:Response->Void, ?onProgress:Array->Void) + /** + * Make a GameJolt API call that is safe to make via softcoding. + * @param call The RequestType call to make. Currently only supports: + * `FRIENDS`, `TIME`, `USER_FETCH`, `SCORES_GETRANK`, and `TROPHIES_FETCH`. + * @param async Whether or not the call should be asyncronous. + * @param onError Callback function if an error occurs. Gives error string. + * @param onComplete Callback function on successful completion of the call. Gives response data. + * @param onProgress Callback function for progress on async calls. Gives a progress float array. + */ + public static function makeCall(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:GJResponse->Void, ?onProgress:Array->Void) { switch(call) { case BATCH(parallel, breakOnError, requests): @@ -314,32 +364,116 @@ class GJUtil case USER_AUTH: return; + case SESSION_OPEN: + return; + + case SESSION_PING(active): + return; + + case SESSION_CHECK: + return; + + case SESSION_CLOSE: + return; + case SCORES_ADD(score, sort, extra_data, table_id): return; case TROPHIES_ADD(trophy_id): return; + case TROPHIES_REMOVE(trophy_id): + return; + case _: send(call, async, onError, onComplete, onProgress); } } - static var pingTime:Int = 0; - static function pingTimer() + public static function unlockCustomTrophy(custom:String, ?callback:Null->Void) { - pingTime += 1; - if (pingTime < 10000) return; - pingTime -= 10000; - pingSession(); + if (!GameJoltData.customTrophies.exists(custom)) + if (callback != null) callback(null) + else { + var daTrophy:GJTrophyData = GameJoltData.customTrophies.get(custom); + + if (GameJoltData.earnedTrophies.exists(custom)) + if (callback != null) callback(null) + else { + var meetsReqs:Bool = true; + if (daTrophy.require != null) { + var reqsMet:Array = []; + for (earned in GameJoltData.earnedTrophies) + if (daTrophy.require.contains(earned.id)) reqsMet.push(earned.id); + + if (reqsMet.length != daTrophy.require.length) + meetsReqs = false; + } + + if (!meetsReqs) + if(callback != null) callback(null) + else { + send(TROPHIES_ADD(daTrophy.id), true, function(err) { + Logs.trace('Trophy unlock error: ${err}', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(null); + }, function(resp) { + GameJoltData.earnedTrophies.set(custom, daTrophy); + if (callback != null) callback(resp.trophies[0]); + }); + } + } + } + } + + public static function getAvatarImage(image:FlxSprite, ?addlCallback:Void->Void) + { + #if ALLOW_MULTITHREADING ThreadUtil.execAsync#elseif (target.threaded) Thread.create#end(function() { + var key:String = 'GAMEJOLT-USER:${userName}'; + var bmap:Dynamic = FlxG.bitmap.get(key); + + if(bmap == null) { + Logs.trace('Downloading avatar: ${userName}', INFO, LIGHTGRAY, 'GameJolt'); + var unfLink:Bool = StringTools.endsWith(userAvatarUrl, '.png'); + + var bytes = null; + if(unfLink) { + try bytes = HttpUtil.requestBytes(userAvatarUrl) + catch(e) Logs.error('Failed to download GameJolt pfp for ${userName}: ${CoolUtil.removeIP(e.message)} - (Retrying using the api..)', RED, 'GameJolt'); + + if(bytes != null) { + bmap = BitmapData.fromBytes(bytes); + } + } + + var leGraphic:FlxGraphic = null; + if(bmap != null) try { + #if (target.threaded) + mutex.acquire(); + #end + leGraphic = FlxG.bitmap.add(bmap, false, key); + leGraphic.persist = true; + bmap = null; + image.loadGraphic(leGraphic); + if (addlCallback != null) addlCallback(); + #if (target.threaded) + mutex.release(); + #end + } catch(e) { + Logs.error('Failed to update the pfp for ${userName}: ${e.message}', RED, 'GameJolt'); + } + } else { + image.loadGraphic(bmap); + if (addlCallback != null) addlCallback(); + } + }); } static function pingSession() { - send(RequestType.SESSION_PING(true), true, (str) -> { + send(SESSION_PING(true), true, (str) -> { if (onLostSession != null) onLostSession(); shutdownFunctions(); - Logs.trace("Session lost; GJUtil shut down successfully.", WARNING, LIGHTGRAY, 'GameJolt'); + Logs.trace('Session lost ($str); GJUtil shut down successfully.', WARNING, LIGHTGRAY, 'GameJolt'); active = false; }); } @@ -357,31 +491,36 @@ class GJUtil Logs.logText(userName, GREEN), Logs.logText('...') ], INFO); - FlxG.signals.postUpdate.remove(pingTimer); + daTimer.stop(); + daTimer = null; openfl.Lib.application.onExit.remove(onExitApp); onLostSession = null; } - @:noPrivateAccess static function send(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:Response->Void, ?onProgress:Array->Void) + @:noPrivateAccess static function send(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:GJResponse->Void, ?onProgress:Array->Void) { if (executing || !active) return; executing = true; @:privateAccess - var resp:Response = GameJoltSecurity.handleRequest(async, call, onProgress); - executing = false; - if (resp.message != null && onError != null) - onError(resp.message); - else if (resp.message == null && onComplete != null) - onComplete(formatImages(resp)); + GameJoltSecurity.handleRequest(async, call, function(errmsg) { + executing = false; + if (onError != null) onError(errmsg); + }, function(resp) { + executing = false; + if (onComplete != null) onComplete(formatImages(resp)); + }, onProgress); } - static function formatImages(res:Response):Response { - if (res.users != null) - for (u in res.users) u.avatar_url = '${u.avatar_url.substring(0, 32)}1000${u.avatar_url.substr(34)}'.replace(".jpg", ".png") - .replace(".webp", ".png"); - if (res.trophies != null) for (t in res.trophies) { + static function formatImages(res:GJResponse):GJResponse { + if (res.users != null) { + for (u in res.users) { + u.avatar_url = '${u.avatar_url.substring(0, 32)}1000${u.avatar_url.substr(34)}'.replace(".jpg", ".png").replace(".webp", ".png"); + } + } + if (res.trophies != null && res.trophies[0] != null) { + for (t in res.trophies) { var newUrl:String = ""; if (t.image_url.startsWith('https://m.')) newUrl = '${t.image_url.substring(0, 37)}1000${t.image_url.substr(40)}'.replace(".jpg", ".png").replace(".webp", ".png"); @@ -398,6 +537,7 @@ class GJUtil } t.image_url = newUrl; }; + } if (res.responses != null) for (res2 in res.responses) res2 = formatImages(res2); return res; } @@ -405,9 +545,51 @@ class GJUtil static function set_userName(name:String):String { loggedIn = (name != null && name != ''); + if (name == null) { + userAvatarUrl = null; + userDescription = null; + GameJoltSecurity.userId = null; + GameJoltSecurity.user_token = null; + + } return userName = name; } #else + public static function init() + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + } + public static function attemptLogin(name:String, token:String, ?callback:Bool->Void, checkCreds:Bool = false, tempLogin:Bool = false) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(false); + } + + public static function logout(wipeSave:Bool = false, tempLogin:Bool = false) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + } + + public static function makeCall(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:GJResponse->Void, ?onProgress:Array->Void) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + } + + public static function unlockCustomTrophy(custom:String, ?callback:Null->Void) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(null); + } + + public static function getAvatarImage(image:FlxSprite, ?addlCallback:Void->Void) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + } + + static function set_userName(name:String):String + { + return userName = name; + } #end } \ No newline at end of file diff --git a/source/funkin/menus/MainMenuState.hx b/source/funkin/menus/MainMenuState.hx index ae44f4b5ce..0690c039d7 100644 --- a/source/funkin/menus/MainMenuState.hx +++ b/source/funkin/menus/MainMenuState.hx @@ -63,31 +63,35 @@ class MainMenuState extends MusicBeatState for (i=>option in optionShit) { - if (option == 'gamejolt') + if (option == 'gamejolt') { + #if GAMEJOLT_API + var menuItem:FlxSprite = new FlxSprite(FlxG.width - 138, FlxG.height - 138).loadGraphic(Paths.image('menus/gamejolt-icon')); + menuItem.setGraphicSize(128); + menuItem.updateHitbox(); + menuItem.animation.add('idle', [0], 1, false); + menuItem.animation.add('selected', [0], 1, false); + menuItem.animation.play('idle'); + menuItem.ID = i; + menuItems.add(menuItem); + menuItem.scrollFactor.set(); + menuItem.antialiasing = true; + #else continue; - var menuItem:FlxSprite = new FlxSprite(0, 60 + (i * 160)); - menuItem.frames = Paths.getFrames('menus/mainmenu/${option}'); - menuItem.animation.addByPrefix('idle', option + " basic", 24); - menuItem.animation.addByPrefix('selected', option + " white", 24); - menuItem.animation.play('idle'); - menuItem.ID = i; - menuItem.screenCenter(X); - menuItems.add(menuItem); - menuItem.scrollFactor.set(); - menuItem.antialiasing = true; + #end + } else { + var menuItem:FlxSprite = new FlxSprite(0, 60 + (i * 160)); + menuItem.frames = Paths.getFrames('menus/mainmenu/${option}'); + menuItem.animation.addByPrefix('idle', option + " basic", 24); + menuItem.animation.addByPrefix('selected', option + " white", 24); + menuItem.animation.play('idle'); + menuItem.ID = i; + menuItem.screenCenter(X); + menuItems.add(menuItem); + menuItem.scrollFactor.set(); + menuItem.antialiasing = true; + } } - var menuItem:FlxSprite = new FlxSprite(FlxG.width - 138, FlxG.height - 138).loadGraphic(Paths.image('menus/gamejolt-icon')); - menuItem.setGraphicSize(128); - menuItem.updateHitbox(); - menuItem.animation.add('idle', [0], 1, false); - menuItem.animation.add('selected', [0], 1, false); - menuItem.animation.play('idle'); - menuItem.ID = menuItems.length; - menuItems.add(menuItem); - menuItem.scrollFactor.set(); - menuItem.antialiasing = true; - FlxG.camera.follow(camFollow, null, 0.06); versionText = new FunkinText(5, FlxG.height - 2, 0, [ diff --git a/source/funkin/menus/gamejolt/GameJoltCompleteScreen.hx b/source/funkin/menus/gamejolt/GameJoltCompleteScreen.hx index 554ec816ac..8215d860bb 100644 --- a/source/funkin/menus/gamejolt/GameJoltCompleteScreen.hx +++ b/source/funkin/menus/gamejolt/GameJoltCompleteScreen.hx @@ -1,16 +1,14 @@ package funkin.menus.gamejolt; -import funkin.editors.ui.UIState; -import funkin.editors.ui.UIButton; -import funkin.editors.ui.UIText; +import funkin.editors.ui.*; import openfl.desktop.Clipboard; import flixel.addons.display.FlxBackdrop; import funkin.backend.system.gamejolt.GameJoltData; import funkin.menus.TitleState; -import funkin.backend.system.Controls; class GameJoltCompleteScreen extends UIState { + var bg:FlxBackdrop; var mainText:UIText; var copyText:UIText; var copyButton:UIButton; @@ -19,8 +17,14 @@ class GameJoltCompleteScreen extends UIState override public function create() { super.create(); - - add(mainText = new UIText(0, 50, FlxG.width, + + add(bg = new FlxBackdrop()); + bg.loadGraphic(Paths.image('editors/bgs/default')); + bg.antialiasing = true; + bg.rotation = -5; + bg.velocity.set(85, 0).degrees = bg.rotation; + + add(mainText = new UIText(0, 80, FlxG.width, 'Hey there funkhead! Your GameJolt data was recognized and registered to your game\'s data store successfully. From here on, only ${GameJoltData.ownerUsername} will be able to change the global data. As for the XML - for security purposes, we took out the login info and game token. Feel free to discard the XML entirely, or keep it as a souvenir - your globals load from your game\'s data store now! @@ -30,11 +34,11 @@ class GameJoltCompleteScreen extends UIState mainText.alignment = CENTER; mainText.antialiasing = true; - add(copyText = new UIText(0, mainText.height + 60, FlxG.width, 'GAMEJOLT_ENCRYPTED_TOKEN=\'${Flags.MOD_GAMEJOLT_ENCRYPTED_TOKEN}\'', 16)); + add(copyText = new UIText(0, mainText.height + 120, FlxG.width, 'GAMEJOLT_ENCRYPTED_TOKEN=\'${Flags.MOD_GAMEJOLT_ENCRYPTED_TOKEN}\'', 16)); copyText.alignment = CENTER; copyText.antialiasing = true; - add(copyButton = new UIButton(0, copyText.y + copyText.height + 10, "Copy", () -> { + add(copyButton = new UIButton(0, copyText.y + copyText.height + 20, "Copy", () -> { Clipboard.generalClipboard.setData(TEXT_FORMAT, copyText.text); })); copyButton.color = 0xFF3F3FFF; diff --git a/source/funkin/menus/gamejolt/GameJoltConfirmationWindow.hx b/source/funkin/menus/gamejolt/GameJoltConfirmationWindow.hx new file mode 100644 index 0000000000..3fbacf8810 --- /dev/null +++ b/source/funkin/menus/gamejolt/GameJoltConfirmationWindow.hx @@ -0,0 +1,53 @@ +package funkin.menus.gamejolt; + +import funkin.editors.ui.*; + +class GameJoltConfirmationWindow extends UISubstateWindow +{ + public var actionType:String; + public var description:String; + public var callback:NullVoid>; + public var closeOnConfirm:Bool; + public var yesText:String; + public var noText:String; + + public var yesButton:UIButton; + public var noButton:UIButton; + + override public function new(actionType:String = 'Logout', description:String = 'No description provided.', ?callback:Void->Void, closeOnConfirm:Bool = true, ?yesText:String, ?noText:String) + { + super(); + this.actionType = actionType; + this.description = description; + this.callback = callback; + this.closeOnConfirm = closeOnConfirm; + this.yesText = yesText != null ? yesText : TU.translate('editor.yes'); + this.noText = noText != null ? noText : TU.translate('editor.cancel'); + } + + override public function create() + { + winWidth = 360; + + winTitle = 'Confirm $actionType'; + + super.create(); + + add(messageSpr = new UIText(windowSpr.x + 20, windowSpr.y + 46, windowSpr.bWidth - 40, description)); + messageSpr.alignment = CENTER; + + windowSpr.resize(winWidth, Std.int(messageSpr.y + messageSpr.height + 68)); + + add(yesButton = new UIButton(windowSpr.x + (windowSpr.bWidth / 2) - 130, windowSpr.y + windowSpr.bHeight - 48, yesText, confirmAndClose, 125)); + add(noButton = new UIButton(windowSpr.x + (windowSpr.bWidth / 2) + 5, windowSpr.y + windowSpr.bHeight - 48, noText, close, 125)); + noButton.color = 0xFFFF0000; + } + + function confirmAndClose() + { + if (callback != null) + callback(); + if (closeOnConfirm) + close(); + } +} \ No newline at end of file diff --git a/source/funkin/menus/gamejolt/GameJoltDataWindow.hx b/source/funkin/menus/gamejolt/GameJoltDataWindow.hx new file mode 100644 index 0000000000..78262feb75 --- /dev/null +++ b/source/funkin/menus/gamejolt/GameJoltDataWindow.hx @@ -0,0 +1,119 @@ +package funkin.menus.gamejolt; + +import funkin.backend.system.gamejolt.*; +import funkin.editors.ui.*; +import funkin.menus.gamejolt.*; +import funkin.menus.MainMenuState; + +enum abstract DataWindowType(String) { + var USER = 'User'; + var GLOBAL = 'Global'; +} + +class GameJoltDataWindow extends UISubstateWindow { + var loadButton:UIButton; + var saveButton:UIButton; + var closeButton:UIButton; + var resetButton:UIButton; + + var type:DataWindowType; + + var daX:Float; + + override public function new(type:DataWindowType) + { + super(); + // Prevent people from manipulating global data + this.type = ((GameJoltSecurity.userId == GameJoltData.ownerUserId && GameJoltSecurity.userId != null) ? type : USER); + } + + override public function create() + { + winTitle = '${type} Data Actions'; + + winWidth = 756; + + super.create(); + + daX = windowSpr.x + 20; + + // Resize window height automatically with height of unused messageSpr. + add(messageSpr = new UIText(daX, windowSpr.y + 46, windowSpr.bWidth - ((daX - windowSpr.x) * 2), type == USER ? "User data includes base Codename Engine options (including base controls), current scores, and any other items defined by this mod's developers." : "Remember that gamejolt.xml file in data/global you used to set up the global items like leaderboards and trophy info? You can retrieve that here or overwrite the current globals here.")); + messageSpr.alignment = CENTER; + windowSpr.resize(winWidth, Std.int(messageSpr.y + messageSpr.height + 68)); + + // Load button for loading user/global data. + add(loadButton = new UIButton(windowSpr.x + (windowSpr.bWidth / 2) - 265, windowSpr.y + windowSpr.bHeight - 48, "Load", () -> { + if (type == USER) + openSubState(new GameJoltConfirmationWindow('User Data Load', 'This will load, and overwrite, any user-specific data saved in this mod\'s data store to the local save.\nAre you sure you want to do this?', () -> { + GameJoltData.loadUserData((bl) -> { + if (bl) + openSubState(new GameJoltInfoWindow('User Data Load Success', 'User data loaded successfully.', close)); + else + openSubState(new GameJoltInfoWindow('User Data Load Error', 'Unable to load user data. Please check console for more information.', close)); + }); + }, false)); + else + openSubState(new GameJoltConfirmationWindow('Global Data Load', 'This will load all global data saved in this mod\'s data store. It will then write it to this mod\'s data/config/gamejolt.xml\nAre you sure you want to do this?', () -> { + GameJoltData.loadGlobalData((bl) -> { + if (bl) + openSubState(new GameJoltInfoWindow('Global Data Load Success', 'Global data loaded successfully into data/config/gamejolt.xml.', close)); + else + openSubState(new GameJoltInfoWindow('Global Data Load Error', 'Unable to load global data. Please check console for more information.', close)); + }); + }, false)); + }, 125)); + + // Save button for writing user/global data. + add(saveButton = new UIButton(windowSpr.x + (windowSpr.bWidth / 2) - 130, windowSpr.y + windowSpr.bHeight - 48, TU.translate("editor.save"), () -> { + if (type == USER) + openSubState(new GameJoltConfirmationWindow('User Data Save', 'This will save any user-specific data saved in this mod\'s data store. If a save exists for this user in the data store, it will overwrite that save.\nAre you sure you want to do this?', () -> { + GameJoltData.setUserData((bl) -> { + if (bl) + openSubState(new GameJoltInfoWindow('User Data Save Success', 'User data saved successfully.', close)); + else + openSubState(new GameJoltInfoWindow('User Data Save Error', 'Unable to save user data. Please check console for more information.', close)); + }); + }, false)); + else + openSubState(new GameJoltConfirmationWindow('Global Data Save', 'This will transfer all global data in data/config/gamejolt.xml to this mod\'s data store. It will overwrite any previous values.\nAre you sure you want to do this?', () -> { + GameJoltData.setGlobalData(false, (bl) -> { + if (bl) + openSubState(new GameJoltInfoWindow('Global Data Save Success', 'Global data saved successfully.', close)); + else + openSubState(new GameJoltInfoWindow('Global Data Save Error', 'Unable to save global data. Please check console for more information.', close)); + }); + }, false)); + }, 125)); + + // Reset button for removing user/global data. + add(resetButton = new UIButton(windowSpr.x + (windowSpr.bWidth / 2) + 5, saveButton.y, "Remove " + (type == USER ? "User Data" : "GJ Integration"), () -> { + if (type == USER) + openSubState(new GameJoltConfirmationWindow('Remove User Data', '!! WARNING !!\nThis will remove ALL user data from this mod\'s data store. This is an irreversible action.\nARE YOU SURE YOU WANT TO DO THIS?', () -> { + GameJoltData.wipeUserData((bl) -> { + if (bl) + openSubState(new GameJoltInfoWindow('User Data Wipe Success', 'User data wiped successfully.', close)); + else + openSubState(new GameJoltInfoWindow('User Data Wipe Error', 'Error wiping user data.', close)); + }); + }, false)); + else + openSubState(new GameJoltConfirmationWindow('Remove GameJolt Integration', '!! WARNING !!\nThis will remove ALL global data from your game\'s data store. You will not be able to use GameJolt integrations for this mod until you open the mod with a properly configured gamejolt.xml file in data/config.\nARE YOU SURE YOU WANT TO DO THIS?', () -> { + GameJoltData.wipeGlobalData((bl) -> { + if (bl) + openSubState(new GameJoltInfoWindow('Global Data Wipe Success', 'Global data wiped successfully. This mod no longer has GameJolt integrations. Now redirecting to the Main Menu.', () -> { + FlxG.state.closeSubState(); + FlxG.switchState(new MainMenuState()); + })); + else + openSubState(new GameJoltInfoWindow('Global Data Wipe Error', 'Error wiping global data.', close)); + }); + }, false)); + }, 125)); + resetButton.color = 0xFFFF0000; + + // The humble "close window" button. + add(closeButton = new UIButton(windowSpr.x + (windowSpr.bWidth / 2) + 140, windowSpr.y + windowSpr.bHeight - 48, TU.translate("editor.close"), close, 125)); + closeButton.color = 0xFFFF0000; + } +} \ No newline at end of file diff --git a/source/funkin/menus/gamejolt/GameJoltInfoWindow.hx b/source/funkin/menus/gamejolt/GameJoltInfoWindow.hx new file mode 100644 index 0000000000..38eef3f43c --- /dev/null +++ b/source/funkin/menus/gamejolt/GameJoltInfoWindow.hx @@ -0,0 +1,48 @@ +package funkin.menus.gamejolt; + +import funkin.editors.ui.*; + +class GameJoltInfoWindow extends UISubstateWindow { + public var infoTitle:String; + public var description:String; + public var closeText:String; + public var callback:NullVoid>; + + public var closeButton:UIButton; + + public var daX:Float; + + override public function new(infoTitle:String = 'General', description:String = "No description provided.", ?callback:Void->Void, ?closeText:String) + { + super(); + this.infoTitle = infoTitle; + this.description = description; + this.closeText = closeText != null ? closeText : TU.translate('editor.ok'); + this.callback = callback; + } + + public override function create() { + //TODO: get translations for text + winTitle = 'Info - $infoTitle'; + + winWidth = 756; + + super.create(); + + daX = windowSpr.x + 20; + + add(messageSpr = new UIText(daX, windowSpr.y + 46, windowSpr.bWidth - ((daX - windowSpr.x) * 2), description)); + messageSpr.alignment = CENTER; + + windowSpr.resize(winWidth, Std.int(messageSpr.y + messageSpr.height + 68)); + + //add(new UISprite(daX, text.y + text.height + 10).loadGraphic(Paths.image())) + add(closeButton = new UIButton(windowSpr.x + (windowSpr.bWidth / 2) - 62, windowSpr.y + windowSpr.bHeight - 48, TU.translate("editor.close"), close, 125)); + } + + public override function close() + { + if (callback != null) callback(); + super.close(); + } +} \ No newline at end of file diff --git a/source/funkin/menus/gamejolt/GameJoltLoginSuccess.hx b/source/funkin/menus/gamejolt/GameJoltLoginSuccess.hx deleted file mode 100644 index 5e27a0c4c6..0000000000 --- a/source/funkin/menus/gamejolt/GameJoltLoginSuccess.hx +++ /dev/null @@ -1,23 +0,0 @@ -package funkin.menus.gamejolt; - -import funkin.editors.ui.UIButton; -import funkin.editors.ui.UISubstateWindow; - -class GameJoltLoginSuccess extends UISubstateWindow -{ - var confirmButton:UIButton; - - public override function create() - { - winTitle = "Login Success!"; - - messageSpr.text = 'You have successfully logged in as ${GJUtil.userName}!'; - - super.create(); - - add(confirmButton = new UIButton(windowSpr.x + (windowSpr.bWidth / 2) - 62, windowSpr.y + windowSpr.bHeight - 48, "Sweet!", function() { - close(); - }, 125)); - confirmButton.color = 0xFF00FF00; - } -} \ No newline at end of file diff --git a/source/funkin/menus/gamejolt/GameJoltLoginWindow.hx b/source/funkin/menus/gamejolt/GameJoltLoginWindow.hx index 84a127cd7d..f166c4103c 100644 --- a/source/funkin/menus/gamejolt/GameJoltLoginWindow.hx +++ b/source/funkin/menus/gamejolt/GameJoltLoginWindow.hx @@ -1,10 +1,7 @@ package funkin.menus.gamejolt; -import funkin.editors.ui.UISubstateWindow; -import funkin.editors.ui.UIButton; -import funkin.editors.ui.UITextBox; -import funkin.editors.ui.UIText; -import funkin.menus.gamejolt.GameJoltTokenInfoWindow; +import funkin.editors.ui.*; +import funkin.menus.gamejolt.*; class GameJoltLoginWindow extends UISubstateWindow { public var usernameBox:UITextBox; @@ -21,8 +18,8 @@ class GameJoltLoginWindow extends UISubstateWindow { //TODO: get translations for text winTitle = "Login with GameJolt..."; - winWidth = 350; - winHeight = 350; + winWidth = 360; + winHeight = 250; super.create(); @@ -33,20 +30,26 @@ class GameJoltLoginWindow extends UISubstateWindow { add(userTokenBox = new UITextBox(daX, usernameBox.y + usernameBox.height + 60, "")); userTokenBox.members.push(userTokenLabel = new UIText(daX, userTokenBox.y - 24, 0, "User Token")); - userTokenBox.members.push(userTokenInfo = new UIButton(daX + userTokenLabel.width, userTokenLabel.y, "?", () -> { FlxG.state.openSubState(new GameJoltTokenInfoWindow()); }, 24, 24)); + userTokenBox.members.push(userTokenInfo = new UIButton(daX + userTokenLabel.width, userTokenLabel.y - 4, "?", () -> { + openSubState(new GameJoltInfoWindow('GameJolt User Token', "!! THIS IS NOT YOUR GAMEJOLT ACCOUNT PASSWORD !!\n\nTo access your game token, click on your profile icon, then click on \"Game Token\".")); + }, 24, 24)); userTokenInfo.color = 0xFF3737FF; userTokenBox.label.textField.displayAsPassword = true; - add(loginButton = new UIButton(windowSpr.x + windowSpr.bWidth - 20 - 125, windowSpr.y + windowSpr.bHeight - 16 - 32, "Login", function() { - if (GJUtil.attemptLogin(usernameBox.label.text, userTokenBox.label.text, true)) { - FlxG.state.openSubState(new GameJoltLoginSuccess()); - close(); - } else { - - } + add(loginButton = new UIButton(windowSpr.x + (windowSpr.bWidth / 2) + 20, windowSpr.y + windowSpr.bHeight - 48, "Login", function() { + GJUtil.attemptLogin(usernameBox.label.text, userTokenBox.label.text, (bl) -> { + openSubState(new GameJoltInfoWindow(bl ? "Login Success!" : "Login Error", bl ? 'You have successfully logged in as ${GJUtil.userName}!' : 'Could not log into GameJolt.', () -> { + if (bl) { + close(); + FlxG.resetState(); + } + }, bl ? 'Sweet!' : TU.translate('editor.ok'))); + }, true); }, 125)); - add(closeButton = new UIButton(loginButton.x - 20 - loginButton.bWidth, loginButton.y, TU.translate("editor.cancel"), close, 125)); + add(closeButton = new UIButton(loginButton.x - loginButton.bWidth - 20, loginButton.y, TU.translate("editor.cancel"), function() { + close(); + }, 125)); closeButton.color = 0xFFFF0000; } } \ No newline at end of file diff --git a/source/funkin/menus/gamejolt/GameJoltMenu.hx b/source/funkin/menus/gamejolt/GameJoltMenu.hx index 0969b2dc08..f113a31d68 100644 --- a/source/funkin/menus/gamejolt/GameJoltMenu.hx +++ b/source/funkin/menus/gamejolt/GameJoltMenu.hx @@ -1,38 +1,168 @@ package funkin.menus.gamejolt; -import funkin.editors.ui.UIState; -import funkin.editors.ui.UIButton; -import funkin.editors.ui.UIText; -import funkin.menus.gamejolt.GameJoltLoginWindow; +import flixel.addons.display.FlxBackdrop; +import flixel.tweens.FlxTween; +import flixel.util.FlxColor; +import funkin.backend.system.gamejolt.*; +import funkin.editors.ui.*; +import funkin.menus.gamejolt.*; class GameJoltMenu extends UIState { - var mainGroup:FlxTypedSpriteGroup = new FlxTypedSpriteGroup(); + // Main page variables + var bg:FlxBackdrop; + var userBorder:UISprite; + var userBackground:UISprite; + var mainTitleText:UIText; + var userPhoto:UISprite; + var subTitleText:UIText; + var trophyOrLoginButton:UIButton; + var leaderOrRegisterButton:UIButton; + var userDataButton:UIButton; + var ownerDataButton(default, null):UIButton; + var logoutButton:UIButton; + // Secondary page variables + var secondaryTitleText:UIText; + var subBox:UISprite; + var mainBox:UISprite; + + var onHome(default, set):Bool; + var secondaryPage(default, set):String; + var isDaOwner(default, null):Bool = (GameJoltSecurity.userId == GameJoltData.ownerUserId && GameJoltSecurity.userId != null); + + var daSprites:FlxTypedSpriteGroup = new FlxTypedSpriteGroup(); override function create() { super.create(); - add(new FlxSprite().loadAnimatedGraphic(Paths.image('menus/menuDesat'))); + add(bg = new FlxBackdrop()); + bg.loadGraphic(Paths.image('editors/bgs/default')); + bg.antialiasing = true; + bg.rotation = -5; + bg.velocity.set(85, 0).degrees = bg.rotation; - createPages(); - } + add(daSprites); - function createPages() - { - var lgIn:Bool = GJUtil.loggedIn; + userBorder = new UISprite(); + userBorder.makeGraphic(264, 264, FlxColor.BLACK); + userBorder.screenCenter(X); + daSprites.add(userBorder); + + userBackground = new UISprite(); + userBackground.makeGraphic(256, 256, FlxColor.GRAY); + userBackground.screenCenter(X); + daSprites.add(userBackground); + + mainTitleText = new UIText(0, 80, FlxG.width, (GJUtil.loggedIn ? "GAMEJOLT: " + GJUtil.userName : "NOT LOGGED IN"), 60); + mainTitleText.alignment = CENTER; + userBorder.y = mainTitleText.y + mainTitleText.height + 20; + userBackground.y = userBorder.y + 4; + daSprites.add(mainTitleText); + + userPhoto = new UISprite(userBackground.x, userBackground.y); + + if (GJUtil.loggedIn) { + GJUtil.getAvatarImage(userPhoto, () -> { + userPhoto.setGraphicSize(256, 256); + userPhoto.updateHitbox(); + userPhoto.screenCenter(X); + userPhoto.visible = true; + }); + userPhoto.screenCenter(X); + daSprites.add(userPhoto); + userPhoto.visible = false; + + subTitleText = new UIText(20, userBorder.y + userBorder.height + 20, FlxG.width - 40, '"${GJUtil.userDescription}"', 16); + subTitleText.alignment = CENTER; + subTitleText.italic = true; + daSprites.add(subTitleText); + + trophyOrLoginButton = new UIButton((FlxG.width / 2) - (isDaOwner ? 470 : 390), FlxG.height - 128, "Achievements", () -> { + secondaryPage = 'achievements'; + onHome = false; + }, 180, 48); + // trophyOrLoginButton.color = 0xFF31FF31; + trophyOrLoginButton.field.size = 23; + daSprites.add(trophyOrLoginButton); + + leaderOrRegisterButton = new UIButton((FlxG.width / 2) - (isDaOwner ? 280 : 190), FlxG.height - 128, "Leaderboards", () -> { + secondaryPage = 'leaderboards'; + onHome = false; + }, 180, 48); + // leaderOrRegisterButton.color = 0xFF31FF31; + leaderOrRegisterButton.field.size = 23; + daSprites.add(leaderOrRegisterButton); + + userDataButton = new UIButton((FlxG.width / 2) - (isDaOwner ? 90 : -10), FlxG.height - 128, "User Data", () -> { + // UI Window - WIP + openSubState(new GameJoltDataWindow(USER)); + }, 180, 48); + // userDataButton.color = 0xFF31FF31; + userDataButton.field.size = 23; + daSprites.add(userDataButton); - mainGroup.add(new UIButton((FlxG.width / 2) - 60, FlxG.height - 200, "Login", () -> { - openSubState(new GameJoltLoginWindow()); - })); + if(isDaOwner) { + ownerDataButton = new UIButton((FlxG.width / 2) + 100, FlxG.height - 128, "Global Data", () -> { + // UI Window - WIP + openSubState(new GameJoltDataWindow(GLOBAL)); + }, 180, 48); + // ownerDataButton.color = 0xFF31FF31; + ownerDataButton.field.size = 23; + daSprites.add(ownerDataButton); + } - add(mainGroup); + logoutButton = new UIButton((FlxG.width / 2) + (isDaOwner ? 290 : 210), FlxG.height - 128, "Logout", () -> { + openSubState(new GameJoltConfirmationWindow('Logout', 'Are you sure you want to log out of GameJolt? This will log you out of ALL mods on this build of CNE!', () -> { + closeSubState(); + GJUtil.logout(true); + FlxG.resetState(); + })); + }, 180, 48); + logoutButton.color = 0xFFFF0000; + logoutButton.field.size = 23; + daSprites.add(logoutButton); - var daGroup:FlxTypedSpriteGroup = new FlxTypedSpriteGroup(); + secondaryTitleText = new UIText(FlxG.width, 80, FlxG.width, "", 60); + secondaryTitleText.alignment = CENTER; + daSprites.add(secondaryTitleText); - daGroup.add(new UIText(0, 0, FlxG.width, "NOT LOGGED IN")); + subBox = new UISprite(FlxG.width + 100, secondaryTitleText.height + 100); + subBox.makeGraphic(Std.int(((FlxG.width - 200) / 4) - 25), Std.int(FlxG.height - subBox.y - 100), FlxColor.WHITE); + daSprites.add(subBox); - add(daGroup); + mainBox = new UISprite(subBox.width + subBox.x + 50, subBox.y); + mainBox.makeGraphic(Std.int((subBox.width * 3) + 25), Std.int(subBox.height), FlxColor.WHITE); + daSprites.add(mainBox); + } else { + userPhoto.loadGraphic(Paths.image('menus/gamejolt-icon')); + userPhoto.setGraphicSize(248, 248); + userPhoto.updateHitbox(); + userPhoto.screenCenter(X); + userPhoto.y += 4; + daSprites.add(userPhoto); + + subTitleText = new UIText(0, userPhoto.y + userPhoto.height + 30, FlxG.width, "Login with your GameJolt account to see leaderboards, get achievements, save your data, and more!", 35); + subTitleText.alignment = CENTER; + daSprites.add(subTitleText); + + trophyOrLoginButton = new UIButton((FlxG.width / 2) + 10, FlxG.height - 128, "Login", () -> { + openSubState(new GameJoltLoginWindow()); + }, 180, 48); + trophyOrLoginButton.color = 0xFF31FF31; + trophyOrLoginButton.field.size = 23; + daSprites.add(trophyOrLoginButton); + + leaderOrRegisterButton = new UIButton((FlxG.width / 2) - 190, FlxG.height - 128, "Register", () -> { + CoolUtil.openURL('https://gamejolt.com/join'); + }, 180, 48); + leaderOrRegisterButton.color = 0xFF31FF31; + leaderOrRegisterButton.field.size = 23; + daSprites.add(leaderOrRegisterButton); + } + + @:bypassAccessor onHome = true; + secondaryPage = 'achievements'; } override function update(elapsed:Float) @@ -41,7 +171,26 @@ class GameJoltMenu extends UIState if (controls.BACK) { CoolUtil.playMenuSFX(CANCEL, 0.7); - FlxG.switchState(new MainMenuState()); + if (onHome) + FlxG.switchState(new MainMenuState()); + else + onHome = true; } } + + function set_onHome(onH:Bool):Bool + { + FlxTween.num(0, FlxG.width, 1, { }, function(num:Float) { + var prcnt:Float = num / FlxG.width; + bg.velocity.set(85 * (1 + (Math.sin(Math.PI * prcnt) * (onH ? -1 : 1)))); + daSprites.x = FlxEase.elasticInOut(onH ? (1 - prcnt) : prcnt) * -FlxG.width; + }); + return onHome = onH; + } + + function set_secondaryPage(type:String):String + { + if (secondaryTitleText != null) secondaryTitleText.text = type.toUpperCase(); + return secondaryPage = type; + } } \ No newline at end of file diff --git a/source/funkin/menus/gamejolt/GameJoltTokenInfoWindow.hx b/source/funkin/menus/gamejolt/GameJoltTokenInfoWindow.hx deleted file mode 100644 index 4751dc45d6..0000000000 --- a/source/funkin/menus/gamejolt/GameJoltTokenInfoWindow.hx +++ /dev/null @@ -1,32 +0,0 @@ -package funkin.menus.gamejolt; - -import funkin.editors.ui.UISprite; -import funkin.editors.ui.UIButton; -import funkin.editors.ui.UIText; -import funkin.editors.ui.UISubstateWindow; - -class GameJoltTokenInfoWindow extends UISubstateWindow { - public var text:UIText; - public var closeButton:UIButton; - - public var daX:Float; - - public override function create() { - //TODO: get translations for text - winTitle = "GameJolt Token Info"; - - winWidth = 756; - winHeight = 220; - - super.create(); - - daX = windowSpr.x + 20; - - add(text = new UIText(daX, windowSpr.y + 46, windowSpr.width - ((daX - windowSpr.x) * 2), "!! THIS IS NOT YOUR GAMEJOLT ACCOUNT PASSWORD !!\n\nTo access your game token, click on your profile icon, then click on \"Game Token\".")); - - //add(new UISprite(daX, text.y + text.height + 10).loadGraphic(Paths.image())) - add(closeButton = new UIButton(windowSpr.x + (windowSpr.bWidth / 2) - 62, windowSpr.y + windowSpr.bHeight - 48, TU.translate("editor.saveClose"), function() { - close(); - }, 125)); - } -} \ No newline at end of file From b9ff61925703b6c71acf08caaa8d43e6ce18f239 Mon Sep 17 00:00:00 2001 From: SplatterDash <95732227+SplatterDash@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:16:47 -0400 Subject: [PATCH 5/5] first full completed GJ integration! with this commit, everything is complete for a first draft! feedback is of course welcome hehe - fully integrated Data to a sort of string format (to fix issues with formatting plain maps between Json.stringify and Json.parse) - fully async calls, including for trophy and profile images - loading trophy images, if fails, will load the default GJ trophy skin (which is stored in local CNE assets) - UI complete and loads trophies, leaderboards, data and more - calls for all defined trophies integrated --- .../images/menus/gamejolt/bronze-secret.png | Bin 0 -> 435 bytes assets/images/menus/gamejolt/bronze.png | Bin 0 -> 430 bytes assets/images/menus/gamejolt/gold-secret.png | Bin 0 -> 435 bytes assets/images/menus/gamejolt/gold.png | Bin 0 -> 431 bytes .../images/menus/gamejolt/platinum-secret.png | Bin 0 -> 435 bytes assets/images/menus/gamejolt/platinum.png | Bin 0 -> 431 bytes .../images/menus/gamejolt/silver-secret.png | Bin 0 -> 435 bytes assets/images/menus/gamejolt/silver.png | Bin 0 -> 429 bytes source/funkin/backend/system/MainState.hx | 4 +- .../backend/system/gamejolt/GameJoltData.hx | 334 +++++++++++--- .../system/gamejolt/GameJoltSecurity.hx | 88 +++- source/funkin/backend/utils/GJUtil.hx | 232 ++++++++-- source/funkin/game/PlayState.hx | 3 + .../menus/gamejolt/GameJoltCompleteScreen.hx | 7 +- source/funkin/menus/gamejolt/GameJoltMenu.hx | 425 +++++++++++++++++- source/funkin/savedata/FunkinSave.hx | 136 +++++- 16 files changed, 1108 insertions(+), 121 deletions(-) create mode 100644 assets/images/menus/gamejolt/bronze-secret.png create mode 100644 assets/images/menus/gamejolt/bronze.png create mode 100644 assets/images/menus/gamejolt/gold-secret.png create mode 100644 assets/images/menus/gamejolt/gold.png create mode 100644 assets/images/menus/gamejolt/platinum-secret.png create mode 100644 assets/images/menus/gamejolt/platinum.png create mode 100644 assets/images/menus/gamejolt/silver-secret.png create mode 100644 assets/images/menus/gamejolt/silver.png diff --git a/assets/images/menus/gamejolt/bronze-secret.png b/assets/images/menus/gamejolt/bronze-secret.png new file mode 100644 index 0000000000000000000000000000000000000000..4f7ba8b542fa9d16327102ac3d3192d200421e2e GIT binary patch literal 435 zcmV;k0ZjghP)NBc))x$)R|tIB{aO?MWNpy_xvu-Zo_Kwy^hCrMfEnZYJQ2|Y zi6GurQ-6q{d7RWl)Qye@69@s((J?5CsE3H~&OE5K$hBh?*V3A;nn(m)9KqCsGU8KE zU*#1M#@z&IK7P^-xh@ccb2{ec<~$TRX-$ONw2EEH2pEVMYU>MBL>N5WxIpq?TkBRt zm`3l&BvyoBS`UHA2Fr@1m*PZ-f03P&pruWoA}i?!{KeRp#2yup)_QMueAyqViGU$M=DP0J9S}7T z8{(gPk3|K<{Yp**W%O_`5kVqihBa<8%ZGsQ58R0*$XHm!TVbiSoQRo9jIyo}KZM$2 zdqza#&cTDABN2k_6;Ypqtu-AqCo*29eOeK@-Vp*ut?>miA{sm_?uc;d@3C!0MAK-F zuxms#O!FaPekrU@@Hr7N5kVrN_IVL*UZ0e-ZYCl(LMC3kkdXlCM1F*~{wyuupV7&= z2I9qKLlDl88gKh5A+7O&D2CgsA#B74MMSrI6%p^Ob>+Fg7BXg4$b%dto}>;k`@tK` zL4+U&*>x80gX}Sj_d)gNBc))x$)R|tIB{aO?MWNpy_xvu-Zo_Kwy^hCrMfEnZYJQ2|Y zi6GurQ-6q{d7RWl)Qye@69@s((J?5CsE3H~&OE5K$hBh?*V3A;nn(m)9KqCsGU8KE zU*#1M#@z&IK7P^-xh@ccb2{ec<~$TRX-$ONw2EEH2pEVMYU>MBL>N5WxIpq?TkBRt zm`3l&BvyoBS`UHA2`IMZ1g?`39e*71+r6yJq~zwMPY{wcgtqZ~IqjB4P}{9LM=PBccW} z2Jvz4v8aaTb&?ZN866KM5CWpB+H+Lue254i%!}HZjD=O)N?Z1FB4bcumGuenEoiUv z84<>v!HuXRh{5&>R2*!ranqd0=WE*23gmdlh#6X|MMi|d!;4E%H-i-R+BPG?G`dIV z91(_TJ_P0-0Ru7N{hSC)KuADnpBLeDd?-QiP=d&fOoo?Nd?4}innZp?%v{ER93ecM zYalMJ8=5eqUtIQ8Le#<2FoyHhklDx``YSB=DkAR8KKZ_03;E1bAvdy-h_tp5!Uu0L z8v#)^vg#~e2U%kluY;^JV%I>%O==^Wczo(+L0lIxVI=--Ru@rs$CQcQYa@izMxIgv Z`3HGobhdJ=f!hE8002ovPDHLkV1ig4xUB#H literal 0 HcmV?d00001 diff --git a/assets/images/menus/gamejolt/platinum-secret.png b/assets/images/menus/gamejolt/platinum-secret.png new file mode 100644 index 0000000000000000000000000000000000000000..f9fbf53e25f31e67ba0946bedff5e293ce607df3 GIT binary patch literal 435 zcmV;k0ZjghP)NBc))x$)R|tIB{aO?MWNpy_xvu-Zo_Kwy^hCrMfEnZYJQ2|Y zi6GurQ-6q{d7RWl)Qye@69@s((J?5CsE3H~&OE5K$hBh?*V3A;nn(m)9KqCsGU8KE zU*#1M#@z&IK7P^-xh@ccb2{ec<~$TRX-$ONw2EEH2pEVMYU>MBL>N5WxIpq?TkBRt zm`3l&BvyoBS`UHA2^(XlV?57L{d5t%$IU-Vm}Q zOw)P@%p2l81tbaY*F<1Im}C$j)YnC5=Ar}wp+rE`Mv_rcB2YvpNuoX?Mg=*v`M!pW za}UIW>p~M|Os%(loe*{KGIR*--H=&tv5Pt9$ za}f~bBB!q6bC5Gu@j1vjGxiK5UeX)U!sGFp8{)pm`qvqVn7)X*E2en3?u`)A8~I8L ZNBc))x$)R|tIB{aO?MWNpy_xvu-Zo_Kwy^hCrMfEnZYJQ2|Y zi6GurQ-6q{d7RWl)Qye@69@s((J?5CsE3H~&OE5K$hBh?*V3A;nn(m)9KqCsGU8KE zU*#1M#@z&IK7P^-xh@ccb2{ec<~$TRX-$ONw2EEH2pEVMYU>MBL>N5WxIpq?TkBRt zm`3l&BvyoBS`UHA2JHHYUlS4YYhl&CF2d96qLc~5T|{k!O#1Lb7Q&1~eT0wxEG^&HXmRd= zL~+>=1TjrMPQgwHI>a>0;rVU|8%2ltM2~wNk?5>_Wxd`DS+g(XMJ|#Tsf)Y`{*-qS zA;?8eoyF%MXUyVrkaI@t8HjJv8^OfOU$5Q!BI{pgLcsJz&>bNa#GJj6U3w#*(gOJh Xt6X!Y@Agv(00000NkvXXu0mjfa2mM~ literal 0 HcmV?d00001 diff --git a/source/funkin/backend/system/MainState.hx b/source/funkin/backend/system/MainState.hx index 8ea5c15ae1..9b50f74e9f 100644 --- a/source/funkin/backend/system/MainState.hx +++ b/source/funkin/backend/system/MainState.hx @@ -169,7 +169,7 @@ class MainState extends FlxState { GJUtil.init(); #end - var startState:Class = GameJoltData.freshStart ? GameJoltCompleteScreen : (Flags.DISABLE_WARNING_SCREEN ? TitleState : funkin.menus.WarningState); + var startState:Class = Flags.DISABLE_WARNING_SCREEN ? TitleState : funkin.menus.WarningState; // In this case if the mod we just loaded a compressed modpack, we can't edit or modify files without decompressing it. if (Options.devMode && Options.allowConfigWarning && !isZipMod) { @@ -184,6 +184,6 @@ class MainState extends FlxState { } } - FlxG.switchState(cast Type.createInstance(startState, [])); + if (!GameJoltData.freshStart) FlxG.switchState(cast Type.createInstance(startState, [])); } } diff --git a/source/funkin/backend/system/gamejolt/GameJoltData.hx b/source/funkin/backend/system/gamejolt/GameJoltData.hx index 4bb63e7670..3e58bdab0b 100644 --- a/source/funkin/backend/system/gamejolt/GameJoltData.hx +++ b/source/funkin/backend/system/gamejolt/GameJoltData.hx @@ -8,95 +8,169 @@ import haxe.crypto.mode.Mode; import haxe.crypto.padding.Padding; import haxe.Json; import sys.io.File; -import flixel.input.keyboard.FlxKey; import funkin.backend.utils.GJUtil; -import funkin.backend.utils.GJUtil.ScoreTable; -import funkin.backend.system.Controls.Control; import funkin.backend.system.gamejolt.GameJoltSecurity; import funkin.backend.assets.AssetSource; +import funkin.menus.gamejolt.GameJoltCompleteScreen; import funkin.savedata.FunkinSave; //region Typedefs +/** + * Global data store format. Used to provide a unanimous + * structure to data loading. + */ typedef CNEGameJoltData = { ownerU:String, ownerI:Int, - defTrophies:Map, - cusTrophies:Map, - leaderboards:Map, - addlData:Map>, + defTrophies:String, + cusTrophies:String, + leaderboards:String, + addlData:String, } +/** + * User data store format. Used to provide a unanimous + * structure to data loading. + */ typedef GameJoltUserData = { options:Dynamic, scores:Dynamic, - ?miscItems:Dynamic, + miscItems:Dynamic, } +/** + * Trophy data. This is not the data pulled from the GameJolt API - + * instead, it allows for things like trophy pre-requisites + * or anything that should be excluded from requirements. + */ typedef GJTrophyData = { id:Int, ?require:Array, ?except:Array, ?hidden:Bool, ?weekName:String, + ?songName:String, } //endregion - +/** + * Data class for GameJolt. + * + * Holds, sets, and modifies variables from the global data store (which can + * only be set by the owner of the GameJolt game) or the user-specific + * data store. + */ class GameJoltData { //region Variables + /** + * Whether to go to the page displaying the key and successful data transfer. + */ public static var freshStart(default, null):Bool = false; + /** + * The username of the owner of the GameJolt page. + * Set using the global data store. + */ public static var ownerUsername:Null = null; + /** + * The ID of the owner of the GameJolt page. + * Cross-checks this alongside the current user to + * determine access to global variables. + */ public static var ownerUserId:Null = null; - public static var leaderboards:Map = []; + /** + * Leaderboards from the global data store. + */ + public static var leaderboards(default, null):Map = new Map(); - public static var definedTrophies:Map = []; + /** + * Any trophies defined specifically for use in hardcoding. + * Set using the global data store. + * To set trophies in hardcode, use the `definitions` var as the `def` + * attribute in the node, then set the parameters and trophy ID. + */ + public static var definedTrophies(default, null):Map = new Map(); - public static var definitions:Array = ['open-first', 'friday-night', 'week', 'complete-all', 'fc-first', 'fc-all']; + /** + * Any valid definitions that can be used in hardcoding. + */ + public static var definitions(default, null):Array = ['open-first', 'friday-night', 'week', 'song', 'complete-all', 'fc-first', 'fc-all', 'death-first']; /** * Custom trophies that mods may want to implement outside of the usual suspects. * Unfortunately, these could be easy to cheese. */ - public static var customTrophies(default, null):Map = []; + public static var customTrophies(default, null):Map = new Map(); - public static var earnedTrophies:Map = []; + /** + * Any trophies that the user already earned. + * Exists to not make an insane amount of calls per game, even if + * they are async. + */ + public static var earnedTrophies:Map = new Map(); - public static var dataToInclude:Map> = []; + /** + * Any pieces of the save data that the game should save in user data. + * Set using the global data store. + */ + public static var dataToInclude:Map> = new Map>(); + /** + * Path of the gamejolt.xml. + * Mainly here to prevent ghost variables where possible. + */ static var xmlPath:String = Paths.xml('config/gamejolt'); - static var helpText:String = 'XML for gamejolt setup.\n + /** + * Help text to be printed in the XML file. + * Mainly here to prevent ghost varialbes where possible. + */ + static var helpText:String = 'XML for gamejolt setup. This stores it to the global database of the game, under the -key CNE_DATASTORE.\n +key CNE_DATASTORE. By storing it there, only the owner of the game can modify -that data or delete it if necessary.\n\n +that data or delete it if necessary. -TO SETUP GAMEJOLT FOR YOUR GAME:\n +TO SETUP GAMEJOLT FOR YOUR GAME: - Ensure that the flag `GAMEJOLT_GAME_ID` in the General section -is set to your game ID.\n -- Input the following nodes into this xml:\n - \n\n +is set to your game ID. +- Input the following nodes into this xml: + - game-token-here\n + game-private-key-here - Run the mod. It will start a session with the owner\'s username, encrypt -your token, and inject the data into the global data store.'; +your game key, and inject the data into the global data store.'; //endregion #if GAMEJOLT_API + /** + * Used for initialization of the global data store and + * GameJolt integrations. + * Only called if the system doesn't recognize a game + * security key. + */ public static function loadAdminData() { // get xml var access = getGJX(); - if (access == null) + if (access == null) { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("Unable to locate "), + Logs.logText("gamejolt.xml", GREEN), + Logs.logText(' in '), + Logs.logText("data/config", GREEN), + Logs.logText(' folder!'), + ], ERROR); return; + } - // return if owner and token nodes are absent - if (!access.hasNode.owner || !access.hasNode.token) { - Logs.trace('Missing owner or token node from gamejolt.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + // return if owner and gamekey nodes are absent + if (!access.hasNode.owner || !access.hasNode.gamekey) { + Logs.trace('Missing owner or gamekey node from gamejolt.xml!', ERROR, LIGHTGRAY, 'GameJolt'); return; } @@ -105,8 +179,8 @@ your token, and inject the data into the global data store.'; return; } - // encrypt token - encryptToken(access.node.token.innerData); + // encrypt gamekey + encryptToken(access.node.gamekey.innerData); // GJUtil.attemptLogin(access.node.owner.att.username, access.node.owner.att.token, (bl) -> { @@ -119,23 +193,27 @@ your token, and inject the data into the global data store.'; ], ERROR); } else { ownerUsername = access.node.owner.att.username; - if(GameJoltSecurity.userId == null) { - ownerUsername = null; - GJUtil.logout(false, true); - return; - } + ownerUserId = GameJoltSecurity.userId; setGlobalData(true, (bl) -> { GJUtil.logout(false, true); - if (bl) - if (buildGamejoltXml()) + if (bl) { + if (buildGamejoltXml()) { freshStart = true; + FlxG.switchState(new GameJoltCompleteScreen()); + } + } }, access); } }, true, true); } //region CNE Globals + /** + * Loads the variables from the global data store. + * @param callback Function to run on failure (false) or success + * (true) to load global data. + */ public static function loadGlobalData(?callback:Bool->Void) { GameJoltSecurity.sendTrusted(DATA_FETCH('CNE_DATASTORE', false), true, function(err) { @@ -144,16 +222,52 @@ your token, and inject the data into the global data store.'; if (callback != null) callback(false); }, function(resp) { var daDat:CNEGameJoltData = cast Json.parse(resp.data); + ownerUsername = daDat.ownerU; ownerUserId = daDat.ownerI; - definedTrophies = daDat.defTrophies; - customTrophies = daDat.cusTrophies; - leaderboards = daDat.leaderboards; - dataToInclude = daDat.addlData; + if (daDat.defTrophies != 'null') { + var itms:Array = daDat.defTrophies.split('---'); + for (indiv in itms) { + var details:Array = indiv.split('-V:'); + definedTrophies.set(details[0], cast Json.parse(details[1])); + } + } + if (daDat.cusTrophies != 'null') { + var itms:Array = daDat.cusTrophies.split('---'); + for (indiv in itms) { + var details:Array = indiv.split('-V:'); + customTrophies.set(details[0], cast Json.parse(details[1])); + } + } + if (daDat.leaderboards != 'null') { + var itms:Array = daDat.leaderboards.split('---'); + for (indiv in itms) { + var details:Array = indiv.split('-V:'); + leaderboards.set(details[0], Std.parseInt(details[1])); + } + } + if (daDat.addlData != 'null') { + var itms:Array = daDat.addlData.split('---'); + for (indiv in itms) { + var details:Array = indiv.split('-V:'); + dataToInclude.set(details[0], details[1].split('-vVv-')); + } + } + if (callback != null) callback(true); }); } + /** + * Sets the variables located in the global data store. + * @param cleanSet Whether or not this is the first time we're setting + * these variables in the global data store. If we're replacing a data + * store that already exists, this should be `false`. + * @param callback Function to run on failure (false) or success + * (true) to set global data. + * @param data Access data to set global data store to. Loads gamejolt.xml + * by default. + */ public static function setGlobalData(cleanSet:Bool = false, ?callback:Bool->Void, ?data:Access) { if (data == null) { @@ -171,6 +285,11 @@ your token, and inject the data into the global data store.'; } } + var trophyDefArray:Array = []; + var trophyCusArray:Array = []; + var leaderArray:Array = []; + var dataArray:Array = []; + // With all of these nodes, it's important to make sure that // what we're importing a) exists, and b) isn't just a blank string. @@ -182,7 +301,7 @@ your token, and inject the data into the global data store.'; if (!definitions.contains(trophyDef.att.def)) customTrophies.set(trophyDef.att.def, { id: Std.parseInt(trophyDef.att.id), - require: (trophyDef.has.require && trophyDef.att.require != '') ? [for (trop in trophyDef.att.require.split(',')) Std.parseInt(trop.trim())] : null, + require: (trophyDef.has.require && trophyDef.att.require != '') ? [for (trop in trophyDef.att.require.split('//')) Std.parseInt(trop.trim())] : null, except: (trophyDef.has.except && trophyDef.att.except != '') ? [for (trop in trophyDef.att.except.split('//')) trop.trim()] : null, hidden: (trophyDef.has.hidden && trophyDef.att.hidden != '') ? (trophyDef.att.hidden == 'true') : null, weekName: null, @@ -191,12 +310,16 @@ your token, and inject the data into the global data store.'; if (trophyDef.att.def == 'week' && (!trophyDef.has.weekName || trophyDef.att.weekName == '')) continue; - definedTrophies.set(trophyDef.att.def + (trophyDef.att.def == 'week' ? '-${trophyDef.att.weekName}': ''), { + if (trophyDef.att.def == 'song' && (!trophyDef.has.songName || trophyDef.att.songName == '')) + continue; + + definedTrophies.set(trophyDef.att.def + (trophyDef.att.def == 'week' ? '-${trophyDef.att.weekName}' : (trophyDef.att.def == 'song' ? '-${trophyDef.att.songName}': '')), { id: Std.parseInt(trophyDef.att.id), require: (trophyDef.has.require && trophyDef.att.require != '') ? [for (trop in trophyDef.att.require.split(',')) Std.parseInt(trop.trim())] : null, except: (trophyDef.has.except && trophyDef.att.except != '') ? [for (trop in trophyDef.att.except.split('//')) trop.trim()] : null, hidden: (trophyDef.has.hidden && trophyDef.att.hidden != '') ? (trophyDef.att.hidden == 'true') : null, weekName: (trophyDef.att.def == 'week' && trophyDef.has.weekName && trophyDef.att.weekName != '') ? trophyDef.att.weekName : null, + songName: (trophyDef.att.def == 'song' && trophyDef.has.songName && trophyDef.att.songName != '') ? trophyDef.att.songName : null, }); } @@ -205,7 +328,7 @@ your token, and inject the data into the global data store.'; continue; customTrophies.set(trophyDef.att.def, { - id: Std.parseInt(trophyDef.att.def), + id: Std.parseInt(trophyDef.att.id), require: (trophyDef.has.require && trophyDef.att.require != '') ? [for (trop in trophyDef.att.require.split(',')) Std.parseInt(trop.trim())] : null, except: (trophyDef.has.except && trophyDef.att.except != '') ? [for (trop in trophyDef.att.except.split('//')) trop.trim()] : null, hidden: (trophyDef.has.hidden && trophyDef.att.hidden != '') ? (trophyDef.att.hidden == 'true') : null, @@ -214,11 +337,20 @@ your token, and inject the data into the global data store.'; } } - if (data.hasNode.leaderboards) for (leaderboard in data.node.leaderboards.nodes.song) { - if (!leaderboard.has.id || !leaderboard.has.name || leaderboard.att.name == '' || leaderboard.att.id == '') - continue; + if (data.hasNode.leaderboards) { + if (data.node.leaderboards.hasNode.song) for (leaderboard in data.node.leaderboards.nodes.song) { + if (!leaderboard.has.id || !leaderboard.has.name || leaderboard.att.name == '' || leaderboard.att.id == '') + continue; + + leaderboards.set('song-' + leaderboard.att.name + (leaderboard.has.diff ? '--D:${leaderboard.att.diff}' : '') + ' (V:${leaderboard.has.vari ? leaderboard.att.vari : 'Default'})', Std.parseInt(leaderboard.att.id)); + } + + if (data.node.leaderboards.hasNode.week) for (leaderboard in data.node.leaderboards.nodes.week) { + if (!leaderboard.has.id || !leaderboard.has.name || leaderboard.att.name == '' || leaderboard.att.id == '') + continue; - leaderboards.set(leaderboard.att.name + (leaderboard.has.diff ? '--D:${leaderboard.att.diff}' : '') + ' (V:${leaderboard.has.vari ? leaderboard.att.vari : 'Default'})', Std.parseInt(leaderboard.att.id)); + leaderboards.set('week-' + leaderboard.att.name + (leaderboard.has.diff ? '--D:${leaderboard.att.diff}' : ''), Std.parseInt(leaderboard.att.id)); + } } if (data.hasNode.data) for (dat in data.node.data.nodes.value) { @@ -231,18 +363,30 @@ your token, and inject the data into the global data store.'; dataToInclude.set(location, curVars); } + for (key => value in definedTrophies) + trophyDefArray.push('$key-V:${Json.stringify(value)}'); + + for (key => value in customTrophies) + trophyCusArray.push('$key-V:${Json.stringify(value)}'); + + for (key => value in leaderboards) + leaderArray.push('$key-V:$value'); + + for (key => value in dataToInclude) + dataArray.push('$key-V:${value.join('-vVv-')}'); + var sendOut:CNEGameJoltData = { ownerU: ownerUsername, ownerI: ownerUserId, - defTrophies: definedTrophies, - cusTrophies: customTrophies, - leaderboards: leaderboards, - addlData: dataToInclude, + defTrophies: (trophyDefArray.length > 0 ? trophyDefArray.join('---') : "null"), + cusTrophies: (trophyCusArray.length > 0 ? trophyCusArray.join('---') : "null"), + leaderboards: (leaderArray.length > 0 ? leaderArray.join('---') : "null"), + addlData: (dataArray.length > 0 ? dataArray.join('---') : "null"), }; Logs.trace('Sending global data...', INFO, LIGHTGRAY, "GameJolt"); - GameJoltSecurity.sendTrusted(DATA_SET('CNE_DATASTORE', Json.stringify(sendOut), false), true, function(err) { + GameJoltSecurity.sendTrusted(DATA_SET('CNE_DATASTORE', Json.stringify(sendOut), false), !cleanSet, function(err) { Logs.trace('Unable to upload global GameJolt data: ${err}', ERROR, LIGHTGRAY, 'GameJolt'); if (cleanSet) { reset(); @@ -255,6 +399,11 @@ your token, and inject the data into the global data store.'; }); } + /** + * Wipes global data store from the GameJolt cloud data. + * @param callback Function to run on failure (false) or success + * (true) to wipe global data. + */ public static function wipeGlobalData(?callback:Bool->Void) { GameJoltSecurity.sendTrusted(DATA_REMOVE('CNE_DATASTORE', false), true, function(err) { @@ -267,17 +416,22 @@ your token, and inject the data into the global data store.'; //endregion //region User Data + /** + * Saves user-specific data in the game's user-specific data store. + * @param callback Function to run on failure (false) or success + * (true) to save user data. + */ public static function setUserData(?callback:Bool->Void) { FunkinSave.flush(); - var addlStuff:Dynamic = {}; + var addlStuff = {}; if (dataToInclude != null) for (elem in dataToInclude.keys()) { var daSave:FlxSave = Reflect.field(elem, "save"); if (daSave == null) continue; - var addlSaveItems:Dynamic = {}; + var addlSaveItems = {}; for (itm in dataToInclude.get(elem)) { var itmVal = Reflect.field(daSave.data, itm); if (itmVal == null) @@ -290,7 +444,7 @@ your token, and inject the data into the global data store.'; var dataToSend:GameJoltUserData = { options: Options.__save.data, scores: FunkinSave.save.data.highscores, - miscItems: addlStuff != [] ? addlStuff : null, + miscItems: addlStuff, }; @@ -316,6 +470,11 @@ your token, and inject the data into the global data store.'; } } + /** + * Loads user-specific data in game's user-specific data store. + * @param callback Function to run on failure (false) or success + * (true) to load user data. + */ public static function loadUserData(?callback:Bool->Void) { var daId:Null = GameJoltSecurity.userId; @@ -337,9 +496,11 @@ your token, and inject the data into the global data store.'; var daData:GameJoltUserData = cast Json.parse(resp.data); Options.__save.mergeData(daData.options, true); FunkinSave.save.data.highscores = daData.scores; - if (daData.miscItems != null) { - for (location in Reflect.fields(daData.miscItems)) { - if (location == null) + if (daData.miscItems != {}) { + // I hate how much reflecting is in this code. + var locs:Array = Reflect.fields(daData.miscItems); + for (location in locs) { + if (location == null || location == '') continue; var daSve:FlxSave = Reflect.field(location, "save"); if (daSve == null) @@ -356,6 +517,11 @@ your token, and inject the data into the global data store.'; } } + /** + * Wipes user-specific data in game's user-specific data store. + * @param callback Function to run on failure (false) or success + * (true) to wipe user data. + */ public static function wipeUserData(?callback:Bool->Void) { var daId:Null = GameJoltSecurity.userId; @@ -372,21 +538,30 @@ your token, and inject the data into the global data store.'; } //endregion + /** + * Resets the data in this class. + * @param fullWipe Whether or not to wipe earnedTrophies only + * (`false`) or all data in this class (`true`). + */ public static function reset(fullWipe:Bool = false) { - earnedTrophies = []; + earnedTrophies.clear(); if (fullWipe) { ownerUsername = null; ownerUserId = null; - leaderboards = []; - definedTrophies = []; - customTrophies = []; - dataToInclude = []; + leaderboards.clear(); + definedTrophies.clear(); + customTrophies.clear(); + dataToInclude.clear(); freshStart = false; } } - static function buildGamejoltXml(?fromXml:Xml):Bool + /** + * Creates an XML file from the data provided in this class. + * @return Bool Whether or not the creation was successful. + */ + static function buildGamejoltXml():Bool { final bodyNode:Xml = Xml.createElement('gamejolt'); final trophyNodes:Xml = Xml.createElement('trophies'); @@ -395,8 +570,8 @@ your token, and inject the data into the global data store.'; final ret:Xml = Xml.createDocument(); for (key => trop in definedTrophies) { - var nodeToTrophy:Xml = Xml.createElement('default'); - nodeToTrophy.set('def', key); + var nodeToTrophy:Xml = Xml.createElement('defined'); + nodeToTrophy.set('def', key.substring(0, trop.weekName != null ? 4 : null)); if (trop.require != null) nodeToTrophy.set('require', trop.require.join(',')); if (trop.except != null) @@ -463,6 +638,11 @@ your token, and inject the data into the global data store.'; return finalBool; } + /** + * Gets the gamejolt.xml file from the specified static location. + * @return Null If the data load was successful, returns an + * Access of the XML data; if unsuccessful, returns `null`. + */ static function getGJX():Null { if (!Paths.assetsTree.existsSpecific(xmlPath, "TEXT", AssetSource.MODS)) @@ -477,6 +657,26 @@ your token, and inject the data into the global data store.'; return access; } + /** + * Encrypts a GameJolt game security key using AES encryption. It then + * sets the `GAMEJOLT_ENCRYPTED_TOKEN` flag to this newly-encrypted token. + * + * ## A note about the key and IV for AES + * The AES key should be kept hidden, and is done so using classes + * inaccessible to Hscript and a .env file that is hidden to open source. + * This .env file generates a random key for a build if the AES key is + * missing - meaning if it gets lost, mods have to re-encrypt their + * game security keys. + * It is common real-world practice however to include the IV alongside the + * encrypted text, as done here. Both the key and IV are required to unlock an + * AES-encrypted text. The key though should be kept as secret as possible, + * as it does most of the heavy lifting in encryption; the IV mainly + * obscures the key and first block of encrypted text. Think of the IV as + * icing on the cake. + * + * And yes - I did academic research for an FNF engine, why do you ask. + * @param token Token to encrypt. + */ static function encryptToken(token:String) { var validHex:String = "0123456789abcdef"; diff --git a/source/funkin/backend/system/gamejolt/GameJoltSecurity.hx b/source/funkin/backend/system/gamejolt/GameJoltSecurity.hx index 0aa56461be..d3c2f7164b 100644 --- a/source/funkin/backend/system/gamejolt/GameJoltSecurity.hx +++ b/source/funkin/backend/system/gamejolt/GameJoltSecurity.hx @@ -3,6 +3,7 @@ package funkin.backend.system.gamejolt; import hscript.IHScriptCustomAccessBehaviour; import funkin.backend.utils.GJUtil; import funkin.backend.utils.GJUtil.RequestType; +import funkin.backend.system.gamejolt.GameJoltData; import haxe.crypto.*; import haxe.crypto.mode.Mode; import haxe.crypto.padding.Padding; @@ -109,11 +110,11 @@ class GameJoltSecurity implements IHScriptCustomAccessBehaviour /** * This is a copy of GJUtil's "send" request, but kept here since Hscript can't get here. * Any calls here are guaranteed to be from hardcoding. - * @param call - * @param async - * @param onError - * @param onComplete - * @param onProgress + * @param call The RequestType call to make. Can make any type of call. + * @param async Whether or not the call should be asyncronous. + * @param onError Callback function if an error occurs. Gives error string. + * @param onComplete Callback function on successful completion of the call. Gives response data. + * @param onProgress Callback function for progress on async calls. Gives a progress float array. */ public static function sendTrusted(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:GJResponse->Void, ?onProgress:Array->Void) { @@ -135,6 +136,67 @@ class GameJoltSecurity implements IHScriptCustomAccessBehaviour }, onProgress); } + /** + * Unlocks a trophy from the `definedTrophies` map - in other words, + * any trophy that is specifically defined in hardcode. + * @param def Key/def of trophy in `definedTrophies` map. + * @param callback Function running on completion of unlock attempt. Returns + * trophy data if successful, `null` if unsuccessful. + */ + public static function unlockDefinedTrophy(def:String, ?callback:Null->Void) + { + if (GameJoltData.definedTrophies == null || !GameJoltData.definedTrophies.exists(def)) { + Logs.error('No defined trophy exists with the key "$def".', RED, 'GameJolt'); + if (callback != null) callback(null); + } else { + var daTrophy:GJTrophyData = GameJoltData.definedTrophies.get(def); + + if (GameJoltData.earnedTrophies.exists(def)) { + Logs.error('User already earned trophy with key "$def"!', RED, 'GameJolt'); + if (callback != null) callback(null); + } else { + var meetsReqs:Bool = true; + if (daTrophy.require != null) { + var reqsMet:Array = []; + for (earned in GameJoltData.earnedTrophies) { + if (daTrophy.require.contains(earned.id)) reqsMet.push(earned.id); + } + + if (reqsMet.length != daTrophy.require.length) + meetsReqs = false; + } + + if (!meetsReqs) { + Logs.error('User does not meet requirements for trophy with the key "$def".', RED, 'GameJolt'); + if(callback != null) callback(null); + } else { + sendTrusted(BATCH(false, true, [TROPHIES_FETCH(false, daTrophy.id), TROPHIES_ADD(daTrophy.id)]), true, function(err) { + Logs.trace('Trophy unlock error: ${err}', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(null); + }, function(resp) { + GameJoltData.earnedTrophies.set(def, daTrophy); + if(resp.responses[0].trophies[0] == null) { + Logs.trace('Trophy with key $def already unlocked!', WARNING, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(null); + } else { + Logs.trace('Trophy unlock: ${resp.responses[0].trophies[0].title}!', SUCCESS, LIGHTGRAY, 'GameJolt'); + if (GJUtil.onTrophyUnlock != null) GJUtil.onTrophyUnlock(resp.responses[0].trophies[0]); + if (callback != null) callback(resp.responses[0].trophies[0]); + } + }); + } + } + } + } + + /** + * Handled the main request without giving out any compromisable data. + * @param async Whether or not the call should be asyncronous. + * @param data The RequestType call to make. Can make any type of call. + * @param onError Callback function if an error occurs. Gives error string. + * @param onComplete Callback function on successful completion of the call. Gives response data. + * @param onProgress Callback function for progress on async calls. Gives a progress float array. + */ static function handleRequest(async:Bool = false, data:RequestType, ?onError:String->Void, ?onComplete:GJResponse->Void, ?onProgress:Array->Void) { if (encryptedGameToken == null || gameId == null) { @@ -200,6 +262,12 @@ class GameJoltSecurity implements IHScriptCustomAccessBehaviour } } + /** + * Parses data from a request to be sent via OpenFL or HTTP request. + * @param request RequestType to be formatted. + * @param signed Whether or not the request should be "signed" using the game token. + * @return String Request link. + */ static function parseType(request:RequestType, signed:Bool = false):String { var command:String = ""; var action:String = ""; @@ -389,7 +457,7 @@ class GameJoltSecurity implements IHScriptCustomAccessBehaviour /** * Setter function for encrypted game token. Also sets revealed game token. - * @param tok + * @param tok New string to set for `encryptedGameToken`. */ static function set_encryptedGameToken(tok:String) { @@ -413,12 +481,19 @@ class GameJoltSecurity implements IHScriptCustomAccessBehaviour return sign('https://api.gamejolt.com/api/game/v1_2${parseType(curCall)}'); } #else + //region No API Integrations public static function sendTrusted(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:GJResponse->Void, ?onProgress:Array->Void) { Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); if (onError != null) onError('GameJolt API not set in Project.xml!'); } + public static function unlockDefinedTrophy(def:String, ?callback:Null->Void) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(null); + } + static function set_encryptedGameToken(tok:String) { return encryptedGameToken = tok; @@ -428,5 +503,6 @@ class GameJoltSecurity implements IHScriptCustomAccessBehaviour { return null; } + //endregion #end } \ No newline at end of file diff --git a/source/funkin/backend/utils/GJUtil.hx b/source/funkin/backend/utils/GJUtil.hx index 88cf311772..8911b7124d 100644 --- a/source/funkin/backend/utils/GJUtil.hx +++ b/source/funkin/backend/utils/GJUtil.hx @@ -1,5 +1,6 @@ package funkin.backend.utils; +import lime.graphics.Image; import haxe.Timer; import flixel.graphics.FlxGraphic; import openfl.display.BitmapData; @@ -175,13 +176,29 @@ enum RequestType { } /** - * GameJolt utility to help with GameJolt functionality. Use this class to determine if your player is logged into GameJolt. - * Will not do anything if there is no provided GameJolt token. + * GameJolt utility to help with GameJolt functionality. Use this class to determine if your player is logged into GameJolt, + * unlock custom trophies, or do anything that is safe to accomplish with softcoding. Will not do anything if there is no + * provided GameJolt token or game ID. * * # IMPORTANT - * If you wish to use this utility, please run your GameJolt game's security code through the Codename Engine - * encryption tool on Codename's website. - * Place the output of that into your modpack.ini under the flag `MOD_GAMEJOLT_TOKEN`. + * If you wish to setup GameJolt API integrations, please follow these steps: + * 1. Place your game's game ID (NOT your game's security key) into your `modpack.ini` + * for the flag `GAMEJOLT_GAME_ID`. + * 2. Create a file in `data/config` named `gamejolt.xml`. + * 3. Create nodes for the following: + * ```xml + * + * your-game-security-key-here + * ``` + * 4. Create nodes for trophies and leaderboards. + * 5. Create nodes for data - any specific items in save files you want to be saved in a user's data store. + * 6. Run the mod in Codename Engine (preferred: run the EXE file from a Command Prompt). + * If successful, you will see a screen with more information; if unsuccessful, you'll see + * an error in the console. + * 7. If successful, press the "Copy" button and place the copied output into your modpack.ini, + * preferrably right under the `GAMEJOLT_GAME_ID` flag. + * 8. Discard the overwritten `gamejolt.xml` file or keep it for any updates you want to make to + * the global data store. * * ## DO NOT PLACE YOUR SECURITY KEY RIGHT INTO THE MODPACK.INI!!!! THAT IS A SECURITY ISSUE!!!! */ @@ -222,7 +239,7 @@ class GJUtil * Helper function that runs when an achievement is unlocked. * Can be useful for notifications. */ - public static var onTrophyUnlock:Null->Void> = null; + public static var onTrophyUnlock:NullVoid> = null; /** * Whether or not the utility is executing a call. @@ -239,6 +256,10 @@ class GJUtil #end #if GAMEJOLT_API + /** + * Initializes the GJUtil class and attempts to log in. + * If needed, also attempts to initialize global data. + */ public static function init() { if (Flags.MOD_GAMEJOLT_GAME_ID == '') @@ -248,9 +269,7 @@ class GJUtil GameJoltData.loadAdminData(); if (Flags.MOD_GAMEJOLT_ENCRYPTED_TOKEN == '') return; - } - - if (FlxG.save.data.gameJoltArray != null) { + } else if (FlxG.save.data.gameJoltArray != null) { var gjDat:Array = FlxG.save.data.gameJoltArray; GJUtil.attemptLogin(gjDat[0], gjDat[1]); } @@ -260,7 +279,13 @@ class GJUtil * Helper function to simplify the login process. * @param name Username of user attempting to login. * @param token User token of user attempting to login. - * @return Bool Whether the attempt was successfull or not. + * @param callback Function to run when attempt is complete. Return bool + * determines if attempt was successful or unsuccessful. + * @param checkCreds Whether or not to check and make sure the user actually + * exists. Helpful for new login attempts (instead of confirmed attempts, like + * those coming from the save file). + * @param tempLogin Whether or not the login is temporary (i.e. first-time global + * data upload) or permanent. */ public static function attemptLogin(name:String, token:String, ?callback:Bool->Void, checkCreds:Bool = false, tempLogin:Bool = false) { @@ -269,14 +294,14 @@ class GJUtil userName = name; GameJoltSecurity.user_token = token; - var batchCalls:Array = [SESSION_OPEN]; + var batchCalls:Array = [SESSION_OPEN, USER_FETCH(name)]; if (checkCreds) batchCalls.unshift(USER_AUTH); if (!tempLogin) { - batchCalls.push(USER_FETCH(name)); - batchCalls.push(TROPHIES_FETCH(true)); + batchCalls.push(TROPHIES_FETCH()); } - send(RequestType.BATCH(true, false, batchCalls), true, function(err) { + + send(RequestType.BATCH(true, false, batchCalls), !tempLogin, function(err) { userName = null; if (callback != null) callback(false); }, function(resp) { @@ -286,6 +311,17 @@ class GJUtil userDescription = resp.responses[checkCreds ? 2 : 1].users[0].developer_description; GameJoltData.loadGlobalData((bl) -> { if (bl) { + for (trop in resp.responses[checkCreds ? 3 : 2].trophies) { + for (key => value in GameJoltData.definedTrophies) { + if (value.id == trop.id && trop.achieved != "false") + GameJoltData.earnedTrophies.set(key, value); + } + for (key => value in GameJoltData.customTrophies) { + if (value.id == trop.id && trop.achieved != "false") + GameJoltData.earnedTrophies.set(key, value); + } + } + Logs.traceColored([ Logs.getPrefix("GameJolt"), Logs.logText("Successfully logged in user "), @@ -299,26 +335,38 @@ class GJUtil FlxG.save.data.gameJoltArray = [userName, token]; FlxG.save.flush(); } + GameJoltSecurity.unlockDefinedTrophy('open-first'); + if (Date.now().getDay() == 5 && Date.now().getHours() >= 18) + GameJoltSecurity.unlockDefinedTrophy('friday-night'); + if (callback != null) callback(true); } else { Logs.trace('Unable to obtain global data. Logging out of GameJolt.', ERROR, LIGHTGRAY, 'GameJolt'); logout(false, true); // so that it doesn't remove functions that don't exist + if (callback != null) callback(false); } }); - } - if (userName != null && callback != null) callback(true); + } else if (callback != null) + callback(true); }); } else if (callback != null) callback(false); } + /** + * Logs out a user from GameJolt. + * @param wipeSave Whether or not to wipe the user credentials from + * the save file. + * @param tempLogin Whether or not the login was temporary (i.e. first-time global + * data upload) or permanent. + */ public static function logout(wipeSave:Bool = false, tempLogin:Bool = false) { if (!active) return; if (!tempLogin) shutdownFunctions(); - send(RequestType.SESSION_CLOSE, true, null, function(resp) { + send(RequestType.SESSION_CLOSE, !tempLogin, null, function(resp) { if (!tempLogin) Logs.traceColored([ Logs.getPrefix("GameJolt"), @@ -390,6 +438,13 @@ class GJUtil } } + /** + * Unlocks a trophy from the `customTrophies` map - in other words, + * any trophy that isn't specifically defined in hardcode. + * @param custom Key/def of trophy in `customTrophies` map. + * @param callback Function running on completion of unlock attempt. Returns + * trophy data if successful, `null` if unsuccessful. + */ public static function unlockCustomTrophy(custom:String, ?callback:Null->Void) { if (!GameJoltData.customTrophies.exists(custom)) @@ -418,6 +473,8 @@ class GJUtil if (callback != null) callback(null); }, function(resp) { GameJoltData.earnedTrophies.set(custom, daTrophy); + Logs.trace('Trophy unlock: ${resp.responses[0].trophies[0].title}!'); + if (onTrophyUnlock != null) onTrophyUnlock(resp.responses[0].trophies[0]); if (callback != null) callback(resp.trophies[0]); }); } @@ -425,20 +482,26 @@ class GJUtil } } - public static function getAvatarImage(image:FlxSprite, ?addlCallback:Void->Void) + /** + * Fetches image of user avatar (using same method as fetching GitHub profile images). + * @param image Sprite to apply the bitmap to. + * @param link Link to get image from. Defaults to logged in user's link if null. + * @param addlCallback Callback function to run on successful completion of attempt. + */ + public static function getAvatarImage(image:FlxSprite, ?link:String, ?addlCallback:Void->Void) { #if ALLOW_MULTITHREADING ThreadUtil.execAsync#elseif (target.threaded) Thread.create#end(function() { - var key:String = 'GAMEJOLT-USER:${userName}'; + var key:String = 'GAMEJOLT-LINK:${link != null ? link : userName}'; var bmap:Dynamic = FlxG.bitmap.get(key); if(bmap == null) { - Logs.trace('Downloading avatar: ${userName}', INFO, LIGHTGRAY, 'GameJolt'); - var unfLink:Bool = StringTools.endsWith(userAvatarUrl, '.png'); + Logs.trace('Downloading avatar: ${link != null ? link : userName}', INFO, LIGHTGRAY, 'GameJolt'); + var unfLink:Bool = StringTools.endsWith(link != null ? link : userAvatarUrl, '.png'); var bytes = null; if(unfLink) { - try bytes = HttpUtil.requestBytes(userAvatarUrl) - catch(e) Logs.error('Failed to download GameJolt pfp for ${userName}: ${CoolUtil.removeIP(e.message)} - (Retrying using the api..)', RED, 'GameJolt'); + try bytes = HttpUtil.requestBytes(link != null ? link : userAvatarUrl) + catch(e) Logs.error('Failed to download GameJolt pfp for ${link != null ? link : userName}: ${e.message} - (Retrying using the api..)', RED, 'GameJolt'); if(bytes != null) { bmap = BitmapData.fromBytes(bytes); @@ -468,6 +531,98 @@ class GJUtil }); } + /** + * Fetches image of a trophy (using same method as fetching GitHub profile images). + * If null, it will set the graphic to one of the default graphics stored in the + * Assets file. + * + * You can also create your own default trophy images by just making "bronze", + * "silver", "gold", and "platnum" trophy images, then placing those in + * "images/menus/gamejolt". + * @param image Sprite to apply the bitmap to. + * @param link Link to get image from. Defaults to logged in user's link if null. + * @param addlCallback Callback function to run on successful completion of attempt. + */ + public static function getTrophyImage(image:FlxSprite, trophy:Trophy, ?addlCallback:Void->Void) + { + #if ALLOW_MULTITHREADING ThreadUtil.execAsync#elseif (target.threaded) Thread.create#end(function() { + var key:String = 'GAMEJOLT-TROP:${trophy.image_url}'; + var bmap:Dynamic = FlxG.bitmap.get(key); + + if(bmap == null) { + Logs.trace('Downloading trophy image for trophy: ${trophy.title}', INFO, LIGHTGRAY, 'GameJolt'); + var unfLink:Bool = StringTools.endsWith(trophy.image_url, '.png'); + + if(unfLink) { + Image.loadFromFile(trophy.image_url).onComplete((img:Image) -> { + try { + #if (target.threaded) + mutex.acquire(); + #end + var leGraphic:FlxGraphic = FlxG.bitmap.add(BitmapData.fromImage(img), false, key); + leGraphic.persist = true; + image.loadGraphic(leGraphic); + if (addlCallback != null) addlCallback(); + #if (target.threaded) + mutex.release(); + #end + } catch(e) { + Logs.error('Failed to update the image for ${trophy.title}: ${e.message}', RED, 'GameJolt'); + } + }).onError((e) -> { + Logs.error('Failed to download trophy image for ${trophy.title}: ${e.message} - (Obtaining default image from files instead...)', RED, 'GameJolt'); + try { + #if (target.threaded) + mutex.acquire(); + #end + var secret:Bool = false; + for (t in GameJoltData.definedTrophies) { + if (t.id == trophy.id && t.hidden != null && !t.hidden) + secret = true; + } + + for (t in GameJoltData.customTrophies) { + if (t.id == trophy.id && t.hidden != null && !t.hidden) + secret = true; + } + var leGraphic:FlxGraphic = FlxG.bitmap.add(BitmapData.fromFile(Paths.image('menus/gamejolt/' + trophy.difficulty.toLowerCase() + (secret ? "-secret" : ""))), false, key); + leGraphic.persist = true; + image.loadGraphic(leGraphic); + if (addlCallback != null) addlCallback(); + #if (target.threaded) + mutex.release(); + #end + } catch(e) { + Logs.error('Failed to update the image for ${trophy.title}: ${e.message}', RED, 'GameJolt'); + } + }); + } else { + Logs.error('Image for ${trophy.title} is not a .png image; obtaining default image from files instead...', RED, 'GameJolt'); + try { + #if (target.threaded) + mutex.acquire(); + #end + var leGraphic:FlxGraphic = FlxG.bitmap.add(BitmapData.fromFile(Paths.image('menus/gamejolt/' + trophy.difficulty.toLowerCase())), false, key); + leGraphic.persist = true; + image.loadGraphic(leGraphic); + if (addlCallback != null) addlCallback(); + #if (target.threaded) + mutex.release(); + #end + } catch(e) { + Logs.error('Failed to update the image for ${trophy.title}: ${e.message}', RED, 'GameJolt'); + } + } + } else { + image.loadGraphic(bmap); + if (addlCallback != null) addlCallback(); + } + }); + } + + /** + * Function to ping session on regular basis. + */ static function pingSession() { send(SESSION_PING(true), true, (str) -> { @@ -478,11 +633,18 @@ class GJUtil }); } + /** + * Function to log out of GameJolt on application exit. + * @param i idk man. + */ static function onExitApp(i:Int) { logout(); } + /** + * Functions to run when safely logging out user from GameJolt. + */ static function shutdownFunctions() { Logs.traceColored([ @@ -495,8 +657,17 @@ class GJUtil daTimer = null; openfl.Lib.application.onExit.remove(onExitApp); onLostSession = null; + GameJoltData.reset(); } + /** + * Main send function for calls and requests using GameJolt API. + * @param call Type of request to send. + * @param async Whether or not the request is asynchronous. + * @param onError Function to run on error. Returns error message as string. + * @param onComplete Function to run on success. Returns response data as typedef `Response`. + * @param onProgress Function to run on async progress. Returns array of floats. + */ @:noPrivateAccess static function send(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:GJResponse->Void, ?onProgress:Array->Void) { if (executing || !active) @@ -513,6 +684,11 @@ class GJUtil }, onProgress); } + /** + * Formats any images received to proper file types + * @param res Response to format. + * @return GJResponse Response with formatted images. + */ static function formatImages(res:GJResponse):GJResponse { if (res.users != null) { for (u in res.users) { @@ -525,15 +701,7 @@ class GJUtil if (t.image_url.startsWith('https://m.')) newUrl = '${t.image_url.substring(0, 37)}1000${t.image_url.substr(40)}'.replace(".jpg", ".png").replace(".webp", ".png"); else { - newUrl = "https://s.gjcdn.net/assets/"; - newUrl += switch (t.image_url.substring(24).replace(".jpg", "").replace(".webp", "")) { - case "trophy-bronze-1": "9c2c91d0"; - case "trophy-silver-1": "b46e352e"; - case "trophy-gold-1": "363ce2dc"; - case "trophy-platinum-1": "92e5330d"; - default: ""; - }; - newUrl += ".png"; + newUrl = 'https://s.gjcdn.net/assets/${t.image_url.substr(24)}'; } t.image_url = newUrl; }; diff --git a/source/funkin/game/PlayState.hx b/source/funkin/game/PlayState.hx index 1b937ca6c8..69e3d7899e 100644 --- a/source/funkin/game/PlayState.hx +++ b/source/funkin/game/PlayState.hx @@ -24,6 +24,7 @@ import funkin.backend.scripting.events.gameplay.*; import funkin.backend.scripting.events.note.*; import funkin.backend.system.Conductor; import funkin.backend.system.RotatingSpriteGroup; +import funkin.backend.system.gamejolt.*; import funkin.editors.SaveWarning; import funkin.editors.charter.Charter; import funkin.editors.charter.CharterSelection; @@ -1764,6 +1765,8 @@ class PlayState extends MusicBeatState deathCounter++; + GameJoltSecurity.unlockDefinedTrophy('death-first'); + openSubState(new GameOverSubstate(event.x, event.y, event.deathCharID, event.isPlayer, event.gameOverSong, event.lossSFX, event.retrySFX)); gameAndCharsEvent("onPostGameOver", event); diff --git a/source/funkin/menus/gamejolt/GameJoltCompleteScreen.hx b/source/funkin/menus/gamejolt/GameJoltCompleteScreen.hx index 8215d860bb..05a623952a 100644 --- a/source/funkin/menus/gamejolt/GameJoltCompleteScreen.hx +++ b/source/funkin/menus/gamejolt/GameJoltCompleteScreen.hx @@ -3,7 +3,8 @@ package funkin.menus.gamejolt; import funkin.editors.ui.*; import openfl.desktop.Clipboard; import flixel.addons.display.FlxBackdrop; -import funkin.backend.system.gamejolt.GameJoltData; +import funkin.backend.system.gamejolt.*; +import funkin.backend.utils.GJUtil; import funkin.menus.TitleState; class GameJoltCompleteScreen extends UIState @@ -55,6 +56,10 @@ class GameJoltCompleteScreen extends UIState super.update(elapsed); if (controls.ACCEPT) { CoolUtil.playMenuSFX(CONFIRM); + if (FlxG.save.data.gameJoltArray != null) { + var gjDat:Array = FlxG.save.data.gameJoltArray; + GJUtil.attemptLogin(gjDat[0], gjDat[1]); + } FlxG.switchState(new TitleState()); } } diff --git a/source/funkin/menus/gamejolt/GameJoltMenu.hx b/source/funkin/menus/gamejolt/GameJoltMenu.hx index f113a31d68..7c844a4a1d 100644 --- a/source/funkin/menus/gamejolt/GameJoltMenu.hx +++ b/source/funkin/menus/gamejolt/GameJoltMenu.hx @@ -1,6 +1,12 @@ package funkin.menus.gamejolt; +import funkin.backend.utils.GJUtil.GJResponse; +import funkin.backend.utils.GJUtil.RequestType; +import openfl.display.BitmapData; +import lime.graphics.Image; +import flixel.graphics.FlxGraphic; import flixel.addons.display.FlxBackdrop; +import flixel.math.FlxRect; import flixel.tweens.FlxTween; import flixel.util.FlxColor; import funkin.backend.system.gamejolt.*; @@ -9,6 +15,15 @@ import funkin.menus.gamejolt.*; class GameJoltMenu extends UIState { + public static var leaderboardLimit:Int = 50; + public static var achRowSpacing:Int = 10; + public static var achRowAmount:Int = 5; + public static var boxContentBorderSize:Int = 5; + public static var achievementSelectScale:Float = 1.2; + public static var achEase:Float->Float = FlxEase.elasticOut; + public static var achScaleTime:Float = 1.5; + public static var displayHiddenTrophies:Bool = false; + // Main page variables var bg:FlxBackdrop; var userBorder:UISprite; @@ -24,14 +39,32 @@ class GameJoltMenu extends UIState // Secondary page variables var secondaryTitleText:UIText; - var subBox:UISprite; - var mainBox:UISprite; + var subBoxBacking:UISprite; + var mainBoxBacking:UISprite; + var mainBoxScroll:UIScrollBar; + var subBoxAch:FlxTypedSpriteGroup; + var mainBoxAch:FlxTypedSpriteGroup; + var missingTextAch:Null = null; + var subBoxLead:FlxTypedSpriteGroup; + var mainBoxLead:FlxTypedSpriteGroup; + var missingTextLead:Null = null; + var trophyTitle:UIText; + var trophyDesc:UIText; + var noScoresLead:UIText; + var rankLead:UIText; var onHome(default, set):Bool; + @:bypassAccessor var daInd(default, set):Int = 0; + var daMax:Int = 0; var secondaryPage(default, set):String; var isDaOwner(default, null):Bool = (GameJoltSecurity.userId == GameJoltData.ownerUserId && GameJoltSecurity.userId != null); - + var scoresMap:Map = new Map(); + var trophiesMap:Map = new Map(); var daSprites:FlxTypedSpriteGroup = new FlxTypedSpriteGroup(); + var noScaleTween:Bool = false; + var inTween:FlxTween = null; + var outTween:FlxTween = null; + override function create() { super.create(); @@ -63,7 +96,8 @@ class GameJoltMenu extends UIState userPhoto = new UISprite(userBackground.x, userBackground.y); if (GJUtil.loggedIn) { - GJUtil.getAvatarImage(userPhoto, () -> { + //region Main Page + GJUtil.getAvatarImage(userPhoto, null, () -> { userPhoto.setGraphicSize(256, 256); userPhoto.updateHitbox(); userPhoto.screenCenter(X); @@ -122,19 +156,167 @@ class GameJoltMenu extends UIState logoutButton.color = 0xFFFF0000; logoutButton.field.size = 23; daSprites.add(logoutButton); + //endregion + //region Secondary Page secondaryTitleText = new UIText(FlxG.width, 80, FlxG.width, "", 60); secondaryTitleText.alignment = CENTER; daSprites.add(secondaryTitleText); - subBox = new UISprite(FlxG.width + 100, secondaryTitleText.height + 100); - subBox.makeGraphic(Std.int(((FlxG.width - 200) / 4) - 25), Std.int(FlxG.height - subBox.y - 100), FlxColor.WHITE); - daSprites.add(subBox); + subBoxBacking = new UISprite(FlxG.width + 100, secondaryTitleText.height + 100); + subBoxBacking.makeGraphic(Std.int(((FlxG.width - 200) / 4) - 25), Std.int(FlxG.height - subBoxBacking.y - 100), FlxColor.GRAY); + daSprites.add(subBoxBacking); + + mainBoxBacking = new UISprite(subBoxBacking.width + subBoxBacking.x + 50, subBoxBacking.y); + mainBoxBacking.makeGraphic(Std.int((subBoxBacking.width * 3) + 25), Std.int(subBoxBacking.height), FlxColor.GRAY); + daSprites.add(mainBoxBacking); + + // for achievements, put the main box on the left and the sub box on the right + mainBoxAch = new FlxTypedSpriteGroup(subBoxBacking.x + boxContentBorderSize, subBoxBacking.y + boxContentBorderSize); + mainBoxAch.clipRect = new FlxRect(0, 0, mainBoxBacking.width - (boxContentBorderSize * 4), mainBoxBacking.height - (boxContentBorderSize * 4)); + daSprites.add(mainBoxAch); + + subBoxAch = new FlxTypedSpriteGroup(subBoxBacking.x + mainBoxBacking.width + boxContentBorderSize + 50, subBoxBacking.y + boxContentBorderSize); + subBoxAch.clipRect = new FlxRect(0, 0, subBoxBacking.width - (boxContentBorderSize * 3), subBoxBacking.height - (boxContentBorderSize * 4)); + daSprites.add(subBoxAch); + + subBoxLead = new FlxTypedSpriteGroup(subBoxBacking.x + boxContentBorderSize, subBoxBacking.y + boxContentBorderSize); + subBoxLead.clipRect = new FlxRect(0, 0, subBoxBacking.width - (boxContentBorderSize * 3), subBoxBacking.height - (boxContentBorderSize * 4)); + daSprites.add(subBoxLead); + + mainBoxLead = new FlxTypedSpriteGroup(mainBoxBacking.x + boxContentBorderSize, mainBoxBacking.y + boxContentBorderSize); + mainBoxLead.clipRect = new FlxRect(0, 0, mainBoxBacking.width - (boxContentBorderSize * 4), mainBoxBacking.height - (boxContentBorderSize * 4)); + daSprites.add(mainBoxLead); + + var reqsToMake:Array = []; + if (GameJoltData.definedTrophies.toString() == '[]' && GameJoltData.customTrophies.toString() == '[]') + daSprites.add(missingTextAch = new UIText(mainBoxAch.x, mainBoxAch.y, mainBoxAch.clipRect.width, "NO TROPHIES FOUND FOR THIS MOD!", 48)); + else + reqsToMake.push(TROPHIES_FETCH()); + + if (GameJoltData.leaderboards.toString() == '[]') + daSprites.add(missingTextLead = new UIText(mainBoxLead.x, mainBoxLead.y, mainBoxLead.clipRect.width, "NO LEADERBOARDS AVAILABLE FOR THIS MOD!", 48)); + else + reqsToMake.push(SCORES_TABLES); + + // do this in a batch to make sure we get everything + GameJoltSecurity.sendTrusted(BATCH(true, false, reqsToMake), true, function(err) { + daSprites.add(missingTextAch = new UIText(mainBoxAch.x, mainBoxAch.y, mainBoxAch.clipRect.width, "UNABLE TO FETCH TROPHIES: " + err, 48)); + daSprites.add(missingTextLead = new UIText(mainBoxLead.x, mainBoxLead.y, mainBoxLead.clipRect.width, "UNABLE TO FETCH LEADERBOARDS: " + err, 48)); + }, function(resp) { + // seeing if we have a response for trophies or scoreboards + var trophyResp:Null = null; + var scoresResp:Null = null; + if (resp.responses.length > 1) { + trophyResp = resp.responses[0]; + scoresResp = resp.responses[1]; + } else { + if (resp.responses[0].trophies == null || resp.responses[0].trophies.length <= 0) + trophyResp = resp.responses[0] + else if (resp.responses[0].tables == null || resp.responses[0].tables.length <= 0) + scoresResp = resp.responses[0]; + } + + // achievement trophy image loading + if (trophyResp == null) { + daSprites.add(missingTextAch = new UIText(mainBoxAch.x, mainBoxAch.y, mainBoxAch.clipRect.width, "NO TROPHIES FOUND FOR THIS MOD!", 48)); + } else { + var placeInd:Int = 0; + var photoSize:Int = Std.int((mainBoxAch.clipRect.width - (achRowSpacing * Math.max(0, achRowAmount - 1))) / achRowAmount); + for (trop in trophyResp.trophies) { + var canDisplay:Bool = false; + var hiddenTrop:Bool = false; + for (t in GameJoltData.definedTrophies) { + if ('${t.id}' == '${trop.id}') { + canDisplay = true; + if (t.hidden != null && t.hidden) + hiddenTrop = true; + } + } + + for (t in GameJoltData.customTrophies) { + if ('${t.id}' == '${trop.id}') { + canDisplay = true; + if (t.hidden != null && t.hidden) + hiddenTrop = true; + } + } + + if (!canDisplay || (hiddenTrop && !displayHiddenTrophies)) + continue; + + var tropSprite:UISprite = new UISprite(((achRowSpacing + photoSize) * (placeInd % achRowAmount)) + (achRowSpacing / 2), ((photoSize + achRowSpacing) * Math.floor(placeInd / achRowAmount)) + (achRowSpacing / 2)); + GJUtil.getTrophyImage(tropSprite, trop, () -> { + tropSprite.setGraphicSize(photoSize, photoSize); + tropSprite.updateHitbox(); + }); + tropSprite.ID = placeInd; + if (trop.achieved == "false") + tropSprite.color = FlxColor.GRAY; + trophiesMap.set(placeInd, trop); + mainBoxAch.add(tropSprite); + placeInd++; + } + + if (placeInd == 0) + daSprites.add(missingTextAch = new UIText(mainBoxAch.x, mainBoxAch.y, mainBoxAch.clipRect.width, "NO TROPHIES FOUND FOR THIS MOD!", 48)); + else { + subBoxAch.add(trophyTitle = new UIText(achRowSpacing / 2, achRowSpacing / 2, subBoxAch.clipRect.width - (boxContentBorderSize * 2), "", 38)); + trophyTitle.alignment = CENTER; + subBoxAch.add(trophyDesc = new UIText(achRowSpacing / 2, achRowSpacing / 2, subBoxAch.clipRect.width - (boxContentBorderSize * 2), "", 20)); + trophyDesc.alignment = CENTER; + } + } + + // leaderboard loading + if (scoresResp == null) + daSprites.add(missingTextLead = new UIText(mainBoxLead.x, mainBoxLead.y, mainBoxLead.clipRect.width, "NO LEADERBOARDS AVAILABLE FOR THIS MOD!", 48)); + else { + var amount:Int = 0; + for (board in scoresResp.tables) { + for (b in GameJoltData.leaderboards) { + if ('$b' == '${board.id}') { + scoresMap.set(board.name, board.id); + amount++; + } + } + } - mainBox = new UISprite(subBox.width + subBox.x + 50, subBox.y); - mainBox.makeGraphic(Std.int((subBox.width * 3) + 25), Std.int(subBox.height), FlxColor.WHITE); - daSprites.add(mainBox); + if (amount == 0) { + daSprites.add(missingTextLead = new UIText(mainBoxLead.x, mainBoxLead.y, mainBoxLead.clipRect.width, "NO LEADERBOARDS AVAILABLE FOR THIS MOD!", 48)); + } else { + var daY:Float = 0; + for (name in scoresMap.keys()) { + var daText:UIText = new UIText(0, daY, subBoxLead.clipRect.width, name, 24); + daText.textField.background = true; + daText.textField.backgroundColor = 0x0011AA00; + daText.wordWrap = false; + daText.autoSize = false; + subBoxLead.add(daText); + daY += daText.height; + } + + for (i in 0...leaderboardLimit) { + var anEntry:GameJoltLeaderboardItem = new GameJoltLeaderboardItem(); + mainBoxLead.add(anEntry); + } + + daSprites.add(noScoresLead = new UIText(mainBoxLead.x, mainBoxLead.y, mainBoxLead.clipRect.width, "NO SCORES AVAILABLE FOR THIS LEADERBOARD!", 48)); + noScoresLead.alignment = CENTER; + daSprites.add(rankLead = new UIText(mainBoxLead.x, mainBoxLead.y, mainBoxLead.clipRect.width, "YOUR RANK: --- (NOT SCORED YET!)", 32)); + rankLead.alignment = CENTER; + } + } + }); + + if (missingTextAch != null) + missingTextAch.alignment = CENTER; + + if (missingTextLead != null) + missingTextLead.alignment = CENTER; + //endregion } else { + //region Login Page userPhoto.loadGraphic(Paths.image('menus/gamejolt-icon')); userPhoto.setGraphicSize(248, 248); userPhoto.updateHitbox(); @@ -159,6 +341,7 @@ class GameJoltMenu extends UIState leaderOrRegisterButton.color = 0xFF31FF31; leaderOrRegisterButton.field.size = 23; daSprites.add(leaderOrRegisterButton); + //endregion } @:bypassAccessor onHome = true; @@ -169,6 +352,22 @@ class GameJoltMenu extends UIState { super.update(elapsed); + var scroll = FlxG.mouse.wheel; + var upP = controls.UP_P; + var downP = controls.DOWN_P; + var leftP = controls.LEFT_P; + var rightP = controls.RIGHT_P; + + if (!onHome) switch (secondaryPage) { + case "leaderboards": + if (subBoxLead.members.length > 1 && (upP || downP || scroll != 0)) + daInd = FlxMath.wrap(daInd + (upP ? -1 : 0) + (downP ? 1 : 0) - scroll, 0, mainBoxLead.members.length - 1); + case "achievements": + if (mainBoxAch.members.length > 1 && (leftP || rightP || scroll != 0)) + daInd = FlxMath.wrap(daInd + (leftP ? -1 : 0) + (rightP ? 1 : 0) - scroll, 0, mainBoxAch.members.length - 1); + case _: + + } if (controls.BACK) { CoolUtil.playMenuSFX(CANCEL, 0.7); if (onHome) @@ -188,9 +387,211 @@ class GameJoltMenu extends UIState return onHome = onH; } + function set_daInd(newInd:Int):Int + { + if (secondaryTitleText != null) switch(secondaryTitleText.text) { + case "LEADERBOARDS": + if (mainBoxLead.members.length > 0) { + for (member in mainBoxLead.members) + member.clearData(); + + if (daInd != newInd && subBoxLead.members[daInd] != null) + subBoxLead.members[daInd].textField.backgroundColor = 0x0011AA00; + subBoxLead.members[newInd].textField.backgroundColor = 0xff11AA00; + var daId:Int = scoresMap.get(subBoxLead.members[newInd].text); + GameJoltSecurity.sendTrusted(BATCH(true, false, [SCORES_FETCH(false, daId, leaderboardLimit), SCORES_FETCH(true, daId)]), true, function(err) { + Logs.error("Leaderboard fetch error: " + err, RED, 'GameJolt'); + }, function(resp) { + if (resp.responses[0].scores == null || resp.responses[0].scores.length <= 0) { + noScoresLead.visible = true; + rankLead.visible = false; + } else { + rankLead.visible = true; + noScoresLead.visible = false; + for (i in 0...resp.responses[0].scores.length) { + var usr = resp.responses[0].scores[i]; + mainBoxLead.members[i].setData(Std.int(mainBoxLead.clipRect.width), Std.int(mainBoxLead.clipRect.height / 8.5), usr.user, usr.score, i); + // fun fact: wanted to try and include pfp's, but the calls would've been a bit much, at least imo. ~SPD + } + + if(resp.responses[1].scores[0] != null) + GameJoltSecurity.sendTrusted(SCORES_GETRANK(resp.responses[1].scores[0].sort, daId), true, function(err) { + Logs.error("Error fetching user rank: " + err, RED, 'GameJolt'); + rankLead.text = 'YOUR RANK: --- (??????)'; + }, function(respSec) { + rankLead.text = 'YOUR RANK: ${respSec.rank != null ? '${respSec.rank}' : '---'} (${resp.responses[1].scores[0].score})'; + }) + else + rankLead.text = 'YOUR RANK: --- (NOT SCORED YET!)'; + } + }); + } + + case "ACHIEVEMENTS": + if (mainBoxAch.members.length > 0) { + if (noScaleTween) { + mainBoxAch.members[daInd].scale.set(1, 1); + mainBoxAch.members[daInd].scale.set(achievementSelectScale, achievementSelectScale); + } else { + FlxTween.cancelTweensOf(mainBoxAch.members[daInd].scale); + FlxTween.cancelTweensOf(mainBoxAch.members[newInd].scale); + FlxTween.tween(mainBoxAch.members[daInd].scale, { x: 1, y: 1 }, achScaleTime, { ease: achEase }); + FlxTween.tween(mainBoxAch.members[newInd].scale, { x: achievementSelectScale, y: achievementSelectScale }, achScaleTime, { ease: achEase }); + } + + trophyTitle.text = trophiesMap.get(mainBoxAch.members[newInd].ID).title.toUpperCase(); + trophyDesc.y = trophyTitle.y + trophyTitle.height + achRowSpacing; + trophyDesc.text = trophiesMap.get(mainBoxAch.members[newInd].ID).description; + } + } + return daInd = newInd; + } + function set_secondaryPage(type:String):String { - if (secondaryTitleText != null) secondaryTitleText.text = type.toUpperCase(); + if (secondaryTitleText != null) switch(type) { + case "leaderboards": + secondaryTitleText.text = type.toUpperCase(); + subBoxAch.active = subBoxAch.visible = mainBoxAch.active = mainBoxAch.visible = false; + if (missingTextAch != null) + missingTextAch.visible = false; + subBoxLead.active = subBoxLead.visible = mainBoxLead.active = mainBoxLead.visible = true; + if (missingTextLead != null) + missingTextLead.visible = true; + subBoxBacking.x = FlxG.width + 100; + mainBoxBacking.x = subBoxBacking.width + subBoxBacking.x + 50; + noScaleTween = true; + daInd = 0; + noScaleTween = false; + + case "achievements": + secondaryTitleText.text = type.toUpperCase(); + subBoxAch.active = subBoxAch.visible = mainBoxAch.active = mainBoxAch.visible = true; + if (missingTextAch != null) + missingTextAch.visible = true; + subBoxLead.active = subBoxLead.visible = mainBoxLead.active = mainBoxLead.visible = false; + if (missingTextLead != null) + missingTextLead.visible = false; + if (noScoresLead != null) + noScoresLead.visible = false; + if (rankLead != null) + rankLead.visible = false; + mainBoxBacking.x = FlxG.width + 100; + subBoxBacking.x = mainBoxBacking.width + mainBoxBacking.x + 50; + noScaleTween = true; + daInd = 0; + noScaleTween = false; + } + return secondaryPage = type; } -} \ No newline at end of file +} + +//region Leaderboard Items +class GameJoltLeaderboardItem extends FlxTypedSpriteGroup +{ + public var selected(default, set):Bool; + + var name(default, set):String; + var score(default, set):String; + var rank(default, set):Int; + + var daWidth(default, set):Int; + var daHeight(default, set):Int; + + var nameText:UIText; + var scoreText:UIText; + var rankText:UIText; + var profilePic:UISprite; + var background:UISprite; + + override public function new() + { + super(); + + add(background = new UISprite()); + background.alpha = 0.6; + background.visible = false; + + add(nameText = new UIText(0, 0, width, "")); + add(scoreText = new UIText(0, 0, width, "")); + add(rankText = new UIText(0, 0, width, "")); + add(profilePic = new UISprite()); + + @:bypassAccessor selected = false; + } + + public function setData(width:Int = 200, height:Int = 200, name:String, score:String, rank:Int, ?image:FlxGraphic) + { + this.name = name; + this.score = score; + this.rank = rank; + + if (image != null) + profilePic.loadGraphic(image); + + this.height = height; + this.width = width; + + active = visible = true; + } + + public function clearData() + { + name = ""; + score = ""; + rank = 0; + height = 0; + width = 0; + active = visible = false; + } + + inline function set_selected(sel:Bool):Bool + { + background.visible = sel; + return selected = sel; + } + + inline function set_name(n:String):String + { + nameText.text = n; + return name = n; + } + + inline function set_score(s:String):String + { + scoreText.text = s; + return score = s; + } + + inline function set_rank(r:Int):Int + { + rankText.text = '$r'; + return rank = r; + } + + inline function set_daWidth(w:Int):Int + { + if (w != 0) { + background.makeGraphic(w, daHeight, FlxColor.YELLOW); + profilePic.x = Std.int(w * 0.01); + rankText.x = Std.int(profilePic.x + profilePic.width + (w * 0.02)); + nameText.x = Std.int(w / 2); + scoreText.fieldWidth = Std.int(w * 0.99); + } + return daWidth = w; + } + + inline function set_daHeight(h:Int):Int + { + if (h != 0) { + if (profilePic.graphic != null) { + background.makeGraphic(daWidth, h, FlxColor.YELLOW); + profilePic.setGraphicSize(Std.int(h * 0.9), Std.int(h * 0.9)); + profilePic.updateHitbox(); + } + } + return daHeight = h; + } +} +//endregion \ No newline at end of file diff --git a/source/funkin/savedata/FunkinSave.hx b/source/funkin/savedata/FunkinSave.hx index b72184ed74..7c58fbea9d 100644 --- a/source/funkin/savedata/FunkinSave.hx +++ b/source/funkin/savedata/FunkinSave.hx @@ -1,7 +1,10 @@ package funkin.savedata; +import funkin.backend.chart.ChartData.ChartMetaData; +import funkin.menus.FreeplayState.FreeplaySonglist; +import funkin.backend.system.gamejolt.GameJoltData.GJTrophyData; import flixel.util.FlxSave; -import lime.app.Application; +import funkin.backend.system.gamejolt.*; import openfl.Lib; import haxe.Serializer; import haxe.Unserializer; @@ -144,6 +147,137 @@ class FunkinSave { var oldHigh = safeGetHighscore(entry); if (force || oldHigh.date == null || oldHigh.score < highscore.score) { highscores.set(entry, highscore); + + // GameJolt is only logged in if GAMEJOLT_API is on, otherwise it's always false. + if (GJUtil.loggedIn) { + switch(entry) { + case HSongEntry(songName, difficulty, variation, changes): + if (changes.length == 0) { + if (GameJoltData.leaderboards.exists('song-$songName--D:$difficulty (V:${variation != null ? variation : 'Default'})')) + GameJoltSecurity.sendTrusted(SCORES_ADD('${highscore.score}', highscore.score, 'accuracy=${highscore.accuracy};date=${highscore.date}', GameJoltData.leaderboards.get('song-$songName--D:$difficulty (V:${variation != null ? variation : 'Default'})')), true, function(err) { + Logs.error('Could not post score to leaderboard: $err', RED, 'GameJolt'); + }, function(resp) { + Logs.trace('Successfully posted score to leaderboard associated with song $songName.', SUCCESS, LIGHTGRAY, 'GameJolt'); + }) + else if (GameJoltData.leaderboards.exists('song-$songName (V:${variation != null ? variation : 'Default'})')) + GameJoltSecurity.sendTrusted(SCORES_ADD('${highscore.score}', highscore.score, 'accuracy=${highscore.accuracy};date=${highscore.date}', GameJoltData.leaderboards.get('song-$songName (V:${variation != null ? variation : 'Default'})')), true, function(err) { + Logs.error('Could not post score to leaderboard: $err', RED, 'GameJolt'); + }, function(resp) { + Logs.trace('Successfully posted score to leaderboard associated with song $songName.', SUCCESS, LIGHTGRAY, 'GameJolt'); + }); + + // song trophy and exception check + if (GameJoltData.definedTrophies.exists('song-$songName')) { + var tropData:GJTrophyData = GameJoltData.definedTrophies.get('song-$songName'); + var hasException:Bool = false; + if (tropData.except != null) { + for (itms in tropData.except) { + var indic:String = itms.substr(0, 3); + var dat:Array = itms.substr(3).split(','); + for (d in dat) d.trim(); + switch (indic) { + case "=D:": //difficulty exception + if (dat.contains(difficulty)) hasException = true; + + case "=V:": //variation exception + if (dat.contains(variation)) hasException = true; + + case _: + // nothing lol + } + } + } + if (!hasException) GameJoltSecurity.unlockDefinedTrophy('song-$songName'); + } + + // first fc check + if (highscore.misses == 0 && GameJoltData.definedTrophies.exists('fc-first')) { + if (!GameJoltData.definedTrophies.get('fc-first').except.contains(songName)) + GameJoltSecurity.unlockDefinedTrophy('fc-first'); + } + + // complete all & fc all achievement check + if (GameJoltData.definedTrophies.exists('complete-all') || GameJoltData.definedTrophies.exists('fc-all')) { + var songList:Array = FreeplaySonglist.get().songs; + var completedSongs:Array = []; + var fcData:Array = []; + for (s in highscores.keys()) { + if (highscores.get(s).score == 0) + continue + else switch (s) { + case HSongEntry(songName, difficulty, variation, changes): + completedSongs.push(songName); + fcData.push(highscores.get(s)); + default: + // oop + } + } + + if (GameJoltData.definedTrophies.exists('complete-all')) + { + var daTrophy:GJTrophyData = GameJoltData.definedTrophies.get('complete-all'); + var songAmount:Int = completedSongs.length; + for (sng in completedSongs) { + if (daTrophy.except.contains(sng)) + songAmount--; + } + if (songAmount == (completedSongs.length - daTrophy.except.length)) + GameJoltSecurity.unlockDefinedTrophy('complete-all'); + } + + if (GameJoltData.definedTrophies.exists('fc-all')) + { + var daTrophy:GJTrophyData = GameJoltData.definedTrophies.get('fc-all'); + var songAmount:Int = 0; + for (sng in completedSongs) { + if (daTrophy.except.contains(sng)) + continue; + + if (fcData[completedSongs.indexOf(sng)].misses == 0) + songAmount++; + } + if (songAmount == (completedSongs.length - daTrophy.except.length)) + GameJoltSecurity.unlockDefinedTrophy('fc-all'); + } + } + + } + case HWeekEntry(weekName, difficulty): + if (GameJoltData.leaderboards.exists('week-$weekName--D:$difficulty')) + GameJoltSecurity.sendTrusted(SCORES_ADD('${highscore.score}', highscore.score, 'accuracy=${highscore.accuracy};date=${highscore.date}', GameJoltData.leaderboards.get('week-$weekName--D:$difficulty')), true, function(err) { + Logs.error('Could not post score to leaderboard: $err', RED, 'GameJolt'); + }, function(resp) { + Logs.trace('Successfully posted score to leaderboard associated with week $weekName.', SUCCESS, LIGHTGRAY, 'GameJolt'); + }) + else if (GameJoltData.leaderboards.exists('week-$weekName')) + GameJoltSecurity.sendTrusted(SCORES_ADD('${highscore.score}', highscore.score, 'accuracy=${highscore.accuracy};date=${highscore.date}', GameJoltData.leaderboards.get('week-$weekName')), true, function(err) { + Logs.error('Could not post score to leaderboard: $err', RED, 'GameJolt'); + }, function(resp) { + Logs.trace('Successfully posted score to leaderboard associated with week $weekName.', SUCCESS, LIGHTGRAY, 'GameJolt'); + }); + + // week trophy and exception check + if (GameJoltData.definedTrophies.exists('week-$weekName')) { + var tropData:GJTrophyData = GameJoltData.definedTrophies.get('week-$weekName'); + var hasException:Bool = false; + if (tropData.except != null) { + for (itms in tropData.except) { + var indic:String = itms.substr(0, 3); + var dat:Array = itms.substr(3).split(','); + for (d in dat) d.trim(); + switch (indic) { + case "=D:": //difficulty exception + if (dat.contains(difficulty)) hasException = true; + + case _: + // nothing lol + } + } + } + if (!hasException) GameJoltSecurity.unlockDefinedTrophy('week-$weekName'); + } + } + } return true; } return false;