diff --git a/backend/achievements.py b/backend/achievements.py
new file mode 100644
index 0000000..07f54f4
--- /dev/null
+++ b/backend/achievements.py
@@ -0,0 +1,245 @@
+"""Achievement fetching and display functionality - uses external SAM.CLI tool to interact with Steam."""
+
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import sys
+from typing import Dict, List, Optional, Any
+
+from logger import logger
+from paths import get_plugin_dir
+
+
+def _write_debug_log(message: str) -> None:
+ """Write debug message to a local log file for easier debugging."""
+ try:
+ # Get backend dir from file path
+ backend_dir = os.path.dirname(os.path.realpath(__file__))
+ debug_log_path = os.path.join(backend_dir, "achievements_debug.log")
+ import datetime
+ timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ with open(debug_log_path, "a", encoding="utf-8") as f:
+ f.write(f"[{timestamp}] {message}\n")
+ except Exception:
+ pass # Don't fail if we can't write debug log
+
+
+def _run_sam_cli(command: str, appid: int, *args) -> Dict[str, Any]:
+ """
+ Executes the SAM.CLI.exe tool with the given arguments.
+
+ Args:
+ command: The command to run (get-achievements, unlock, lock)
+ appid: The AppID of the game
+ *args: Additional arguments for the command
+
+ Returns:
+ Dict containing the JSON response from the CLI
+ """
+ try:
+ plugin_dir = get_plugin_dir()
+ sam_cli_path = os.path.join(plugin_dir, "vendor", "SAM", "SAM.CLI.exe")
+
+ if not os.path.exists(sam_cli_path):
+ error_msg = f"SAM.CLI.exe not found at: {sam_cli_path}"
+ logger.warn(f"LuaTools: {error_msg}")
+ _write_debug_log(f"ERROR: {error_msg}")
+ return {"success": False, "error": "SAM.CLI tool not found. Please reinstall LuaTools."}
+
+ cmd_args = [sam_cli_path, command, str(appid)]
+ cmd_args.extend([str(arg) for arg in args])
+
+ logger.log(f"LuaTools: Running SAM CLI: {' '.join(cmd_args)}")
+ _write_debug_log(f"Running SAM CLI: {' '.join(cmd_args)}")
+
+ # Run the process
+ # CREATE_NO_WINDOW flag for Windows to avoid popping up a console window
+ creationflags = 0x08000000 if sys.platform == "win32" else 0
+
+ process = subprocess.Popen(
+ cmd_args,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ creationflags=creationflags
+ )
+
+ stdout_bytes, stderr_bytes = process.communicate()
+
+ # Handle encoding properly - SAM.CLI outputs in console encoding (CP850/CP1252 on Windows)
+ # Python defaults to UTF-8, but console uses system locale
+ try:
+ if sys.platform == "win32":
+ # Try CP850 first (Brazilian console), then fallback to CP1252, then UTF-8
+ try:
+ stdout = stdout_bytes.decode('cp850')
+ except UnicodeDecodeError:
+ try:
+ stdout = stdout_bytes.decode('cp1252')
+ except UnicodeDecodeError:
+ stdout = stdout_bytes.decode('utf-8', errors='replace')
+ else:
+ stdout = stdout_bytes.decode('utf-8')
+ except Exception:
+ stdout = stdout_bytes.decode('utf-8', errors='replace')
+
+ stderr = stderr_bytes.decode('utf-8', errors='replace') if stderr_bytes else ""
+
+ if stderr:
+ _write_debug_log(f"SAM CLI STDERR: {stderr}")
+
+ if stdout:
+ try:
+ # Find the last line that looks like JSON
+ lines = stdout.strip().split('\n')
+ json_str = ""
+
+ # Sometimes there might be debug output before the JSON
+ # We look for the start of the JSON object
+ full_output = stdout.strip()
+ json_start = full_output.find('{')
+ json_end = full_output.rfind('}')
+
+ if json_start != -1 and json_end != -1:
+ json_str = full_output[json_start:json_end+1]
+ return json.loads(json_str)
+ else:
+ _write_debug_log(f"Could not find JSON in output: {stdout}")
+ return {"success": False, "error": "Invalid output from SAM CLI"}
+ except json.JSONDecodeError as e:
+ _write_debug_log(f"Failed to parse JSON: {e}, Output: {stdout}")
+ return {"success": False, "error": "Failed to parse SAM CLI output"}
+
+ return {"success": False, "error": "No output from SAM CLI"}
+
+ except Exception as e:
+ error_msg = f"Exception running SAM CLI: {str(e)}"
+ logger.warn(f"LuaTools: {error_msg}")
+ _write_debug_log(error_msg)
+ import traceback
+ _write_debug_log(traceback.format_exc())
+ return {"success": False, "error": str(e)}
+
+
+def get_player_achievements(appid: int) -> Dict:
+ """
+ Get player's unlocked achievements using SAM.CLI.
+ """
+ return _run_sam_cli("get-achievements", appid)
+
+
+def unlock_achievement(appid: int, achievement_id: str) -> Dict:
+ """
+ Unlock an achievement using SAM.CLI.
+ """
+ return _run_sam_cli("unlock", appid, achievement_id)
+
+
+def lock_achievement(appid: int, achievement_id: str) -> Dict:
+ """
+ Lock an achievement using SAM.CLI.
+ """
+ return _run_sam_cli("lock", appid, achievement_id)
+
+
+def get_all_achievements_for_app(appid: int) -> Dict:
+ """
+ Get all achievements for an app using SAM.CLI.
+ Note: get-achievements already returns all achievements with their status.
+ """
+ return _run_sam_cli("get-achievements", appid)
+
+
+def import_achievements(appid: int, achievements_list: List) -> Dict:
+ """
+ Import achievements from a backup list.
+ Only unlocks achievements marked as unlocked in the backup.
+ """
+ try:
+ appid = int(appid)
+ if not isinstance(achievements_list, list):
+ return {"success": False, "error": "Achievements list must be an array"}
+
+ # Detect format: list of objects (old) or list of IDs (new)
+ if len(achievements_list) > 0:
+ first_item = achievements_list[0]
+ if isinstance(first_item, dict):
+ # Old format: list of achievement objects
+ unlocked_to_import = [ach for ach in achievements_list if ach.get("unlocked", False)]
+ achievement_ids = [ach.get("id", "").strip() for ach in unlocked_to_import if ach.get("id", "").strip()]
+ elif isinstance(first_item, str):
+ # New format: list of achievement IDs (already filtered to unlocked only)
+ achievement_ids = [ach_id.strip() for ach_id in achievements_list if ach_id.strip()]
+ unlocked_to_import = [{"id": ach_id} for ach_id in achievement_ids] # For counting
+ else:
+ return {"success": False, "error": "Invalid achievement list format"}
+ else:
+ return {"success": False, "error": "Achievements list is empty"}
+
+ if not achievement_ids:
+ return {"success": False, "error": "No unlocked achievements found in backup"}
+
+ logger.log(f"LuaTools: Importing {len(achievement_ids)} unlocked achievements for appid {appid}")
+
+ imported_count = 0
+ failed_count = 0
+ failed_list = []
+
+ # Import each unlocked achievement with progress tracking (1 per second)
+ import time
+ for i, ach_id in enumerate(achievement_ids, 1):
+ logger.log(f"LuaTools: Processing achievement {i}/{len(achievement_ids)}: {ach_id}")
+ try:
+ result = unlock_achievement(appid, ach_id)
+ if result.get("success"):
+ imported_count += 1
+ logger.log(f"LuaTools: Successfully imported {ach_id} ({imported_count}/{len(achievement_ids)})")
+ _write_debug_log(f"Imported achievement: {ach_id}")
+ else:
+ failed_count += 1
+ error_msg = result.get("error", "Unknown error")
+ failed_list.append(f"{ach_id}: {error_msg}")
+ logger.log(f"LuaTools: Failed to import {ach_id}: {error_msg}")
+ _write_debug_log(f"Failed to import {ach_id}: {error_msg}")
+ except Exception as exc:
+ failed_count += 1
+ failed_list.append(f"{ach_id}: Exception - {str(exc)}")
+ logger.log(f"LuaTools: Exception importing {ach_id}: {exc}")
+ _write_debug_log(f"Exception importing {ach_id}: {exc}")
+
+ # Wait 1 second between achievements for better progress visibility
+ if i < len(achievement_ids): # Don't wait after the last one
+ time.sleep(1)
+
+ result_msg = f"Imported {imported_count} achievement(s)"
+ if failed_count > 0:
+ result_msg += f", {failed_count} failed"
+
+ response = {
+ "success": imported_count > 0,
+ "imported": imported_count,
+ "failed": failed_count,
+ "total_attempted": len(achievement_ids),
+ "message": result_msg
+ }
+
+ if failed_list:
+ response["failed_details"] = failed_list[:10] # Limit to first 10 failures
+
+ logger.log(f"LuaTools: Import completed: {result_msg}")
+ return response
+
+ except Exception as exc:
+ error_msg = f"Failed to import achievements: {exc}"
+ logger.warn(f"LuaTools: {error_msg}")
+ _write_debug_log(f"EXCEPTION: {error_msg}")
+ import traceback
+ _write_debug_log(f"Traceback: {traceback.format_exc()}")
+ return {"success": False, "error": str(exc)}
+
+
+def get_achievements_for_app(appid: int) -> str:
+ """Wrapper function to return JSON string."""
+ result = get_player_achievements(appid)
+ return json.dumps(result)
diff --git a/backend/main.py b/backend/main.py
index 891fbd1..7f10974 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -53,6 +53,13 @@
get_translation_map,
)
from steam_utils import detect_steam_install_path, get_game_install_path_response, open_game_folder
+from achievements import (
+ get_achievements_for_app,
+ unlock_achievement,
+ lock_achievement,
+ get_all_achievements_for_app,
+ import_achievements,
+)
logger = shared_logger
@@ -193,6 +200,152 @@ def GetUnfixStatus(appid: int, contentScriptQuery: str = "") -> str:
return get_unfix_status(appid)
+def GetAchievementsForApp(appid: int, contentScriptQuery: str = "") -> str:
+ """Get player's unlocked achievements for an app."""
+ return get_achievements_for_app(appid)
+
+
+def UnlockAchievement(appid: int, achievementId: str = "", contentScriptQuery: str = "", **kwargs: Any) -> str:
+ """
+ Unlock an achievement for an app.
+ Parameters can be passed via kwargs or as separate arguments.
+ """
+ try:
+ # Try to get from kwargs first
+ if not achievementId and "achievementId" in kwargs:
+ achievementId = kwargs["achievementId"]
+ if not achievementId and "achievement_id" in kwargs:
+ achievementId = kwargs["achievement_id"]
+
+ # Also check if passed as appid, achievementId from frontend
+ if not achievementId:
+ # Try to parse from contentScriptQuery or kwargs
+ payload = kwargs.get("payload") or kwargs.get("data")
+ if isinstance(payload, dict):
+ achievementId = payload.get("achievementId") or payload.get("achievement_id")
+ elif isinstance(payload, str):
+ try:
+ payload_dict = json.loads(payload)
+ achievementId = payload_dict.get("achievementId") or payload_dict.get("achievement_id")
+ except Exception:
+ pass
+
+ if not achievementId:
+ return json.dumps({"success": False, "error": "achievementId parameter required"})
+
+ result = unlock_achievement(int(appid), str(achievementId))
+ return json.dumps(result)
+ except Exception as exc:
+ logger.warn(f"LuaTools: UnlockAchievement failed: {exc}")
+ return json.dumps({"success": False, "error": str(exc)})
+
+
+def LockAchievement(appid: int, achievementId: str = "", contentScriptQuery: str = "", **kwargs: Any) -> str:
+ """
+ Lock (remove unlock) an achievement for an app.
+ Parameters can be passed via kwargs or as separate arguments.
+ """
+ try:
+ # Try to get from kwargs first
+ if not achievementId and "achievementId" in kwargs:
+ achievementId = kwargs["achievementId"]
+ if not achievementId and "achievement_id" in kwargs:
+ achievementId = kwargs["achievement_id"]
+
+ # Also check if passed as payload
+ if not achievementId:
+ payload = kwargs.get("payload") or kwargs.get("data")
+ if isinstance(payload, dict):
+ achievementId = payload.get("achievementId") or payload.get("achievement_id")
+ elif isinstance(payload, str):
+ try:
+ payload_dict = json.loads(payload)
+ achievementId = payload_dict.get("achievementId") or payload_dict.get("achievement_id")
+ except Exception:
+ pass
+
+ if not achievementId:
+ return json.dumps({"success": False, "error": "achievementId parameter required"})
+
+ result = lock_achievement(int(appid), str(achievementId))
+ return json.dumps(result)
+ except Exception as exc:
+ logger.warn(f"LuaTools: LockAchievement failed: {exc}")
+ return json.dumps({"success": False, "error": str(exc)})
+
+
+def GetAllAchievementsForApp(appid: int, contentScriptQuery: str = "") -> str:
+ """Get all achievements (unlocked and locked) for an app."""
+ try:
+ result = get_all_achievements_for_app(int(appid))
+ return json.dumps(result)
+ except Exception as exc:
+ logger.warn(f"LuaTools: GetAllAchievementsForApp failed: {exc}")
+ return json.dumps({"success": False, "error": str(exc)})
+ return get_achievements_for_app(appid)
+
+
+def ImportAchievements(
+ contentScriptQuery: str = "", achievements_list: Any = None, **kwargs: Any
+) -> str:
+ """
+ Import achievements from a backup list.
+ Follows the same pattern as ApplySettingsChanges.
+ """
+ try:
+ # Debug logging
+ logger.log(f"LuaTools: ImportAchievements called with achievements_list type: {type(achievements_list)}, value: {achievements_list!r}")
+ logger.log(f"LuaTools: ImportAchievements kwargs keys: {list(kwargs.keys())}, values: {kwargs}")
+
+ # Get appid from kwargs (Millennium passes appid as named parameter)
+ appid = kwargs.get("appid", 0)
+ logger.log(f"LuaTools: Extracted appid: {appid}")
+
+ # Check if achievements_list is in kwargs instead
+ if achievements_list is None and "achievements_list" in kwargs:
+ achievements_list = kwargs["achievements_list"]
+ logger.log(f"LuaTools: Found achievements_list in kwargs")
+ elif achievements_list is None and "achievements" in kwargs:
+ achievements_list = kwargs["achievements"]
+ logger.log(f"LuaTools: Found achievements in kwargs")
+
+ # If achievements_list is a string, try to parse as JSON
+ if isinstance(achievements_list, str):
+ try:
+ parsed = json.loads(achievements_list)
+ logger.log(f"LuaTools: Parsed achievements_list JSON, type: {type(parsed)}")
+ achievements_list = parsed
+ except Exception as parse_exc:
+ logger.log(f"LuaTools: Failed to parse achievements_list as JSON: {parse_exc}")
+
+ # If achievements_list is still None, check if the entire kwargs is the data
+ if achievements_list is None and isinstance(kwargs, dict) and len(kwargs) > 1:
+ # Look for achievements in the kwargs
+ for key, value in kwargs.items():
+ if key not in ["appid", "contentScriptQuery"] and isinstance(value, list):
+ achievements_list = value
+ logger.log(f"LuaTools: Found achievements list in kwargs key: {key}")
+ break
+
+ if achievements_list is None:
+ logger.log(f"LuaTools: No achievements_list found. achievements_list: {achievements_list}, kwargs: {kwargs}")
+ return json.dumps({"success": False, "error": "achievements_list parameter required"})
+
+ if not isinstance(achievements_list, list):
+ logger.log(f"LuaTools: achievements_list is not a list: {type(achievements_list)}")
+ return json.dumps({"success": False, "error": "achievements_list must be an array"})
+
+ appid = int(appid)
+ logger.log(f"LuaTools: Processing import for appid {appid} with {len(achievements_list)} achievements")
+
+ result = import_achievements(appid, achievements_list)
+ return json.dumps(result)
+
+ except Exception as exc:
+ logger.warn(f"LuaTools: ImportAchievements failed: {exc}")
+ import traceback
+ logger.warn(f"LuaTools: ImportAchievements traceback: {traceback.format_exc()}")
+ return json.dumps({"success": False, "error": str(exc)})
def GetInstalledFixes(contentScriptQuery: str = "") -> str:
return get_installed_fixes()
diff --git a/build.bat b/build.bat
new file mode 100644
index 0000000..73632da
--- /dev/null
+++ b/build.bat
@@ -0,0 +1,125 @@
+@echo off
+REM Build Script for LuaTools Steam Plugin
+REM Creates a ZIP file ready for distribution
+
+setlocal enabledelayedexpansion
+
+REM Parse arguments
+set "OUTPUT_NAME=ltsteamplugin.zip"
+set "CLEAN=0"
+
+:parse_args
+if "%~1"=="" goto args_done
+if /i "%~1"=="-Clean" (
+ set "CLEAN=1"
+ shift
+ goto parse_args
+)
+if /i "%~1"=="--Clean" (
+ set "CLEAN=1"
+ shift
+ goto parse_args
+)
+if "%~1"=="" goto args_done
+set "OUTPUT_NAME=%~1"
+shift
+goto parse_args
+
+:args_done
+
+REM Project root directory
+set "ROOT_DIR=%~dp0"
+set "ROOT_DIR=%ROOT_DIR:~0,-1%"
+set "OUTPUT_PATH=%ROOT_DIR%\%OUTPUT_NAME%"
+
+echo === LuaTools Build Script ===
+echo Root directory: %ROOT_DIR%
+
+REM Clean previous build if requested
+if "%CLEAN%"=="1" (
+ if exist "%OUTPUT_PATH%" (
+ echo Removing previous build...
+ del /f /q "%OUTPUT_PATH%"
+ )
+)
+
+REM Validate project structure
+echo.
+echo Validating project structure...
+
+set "REQUIRED_FILES=plugin.json backend\main.py public\luatools.js"
+set "VALIDATION_FAILED=0"
+
+for %%f in (%REQUIRED_FILES%) do (
+ if not exist "%ROOT_DIR%\%%f" (
+ echo ERROR: Required file not found: %%f
+ set "VALIDATION_FAILED=1"
+ )
+)
+
+if "%VALIDATION_FAILED%"=="1" (
+ exit /b 1
+)
+
+echo Structure validated successfully!
+
+REM Read version from plugin.json
+set "VERSION=unknown"
+python --version >nul 2>&1
+if %ERRORLEVEL% EQU 0 (
+ if exist "%ROOT_DIR%\scripts\get_version.py" (
+ for /f "tokens=*" %%v in ('python "%ROOT_DIR%\scripts\get_version.py" "%ROOT_DIR%" 2^>nul') do (
+ set "VERSION=%%v"
+ )
+ )
+ if not "!VERSION!"=="unknown" (
+ echo Plugin version: !VERSION!
+ ) else (
+ echo WARNING: Could not read version from plugin.json
+ )
+) else (
+ echo WARNING: Could not read version from plugin.json (Python may not be installed)
+)
+
+REM Validate locales (optional)
+echo.
+echo Validating locale files...
+python --version >nul 2>&1
+if %ERRORLEVEL% EQU 0 (
+ pushd "%ROOT_DIR%"
+ python scripts\validate_locales.py
+ if %ERRORLEVEL% NEQ 0 (
+ echo WARNING: Locale validation failed, but continuing...
+ ) else (
+ echo Locales validated successfully!
+ )
+ popd
+) else (
+ echo WARNING: Could not validate locales (Python may not be installed)
+)
+
+REM Create ZIP file
+echo.
+echo Creating ZIP file...
+
+REM Use simplified PowerShell script with Compress-Archive for compatibility
+if not exist "%ROOT_DIR%\scripts\build_zip_simple.ps1" (
+ echo ERROR: build_zip_simple.ps1 script not found
+ exit /b 1
+)
+
+if exist "%OUTPUT_PATH%" del /f /q "%OUTPUT_PATH%"
+
+powershell -NoProfile -ExecutionPolicy Bypass -File "%ROOT_DIR%\scripts\build_zip_simple.ps1" -RootDir "%ROOT_DIR%" -OutputZip "%OUTPUT_PATH%" -Version "%VERSION%"
+
+if errorlevel 1 (
+ echo.
+ echo ERROR creating ZIP: PowerShell script failed
+ exit /b 1
+)
+
+echo.
+echo === Build Finished ===
+
+endlocal
+
diff --git a/build.ps1 b/build.ps1
index 7d1d9ae..fb87305 100644
--- a/build.ps1
+++ b/build.ps1
@@ -73,6 +73,7 @@ Write-Host "`nCreating ZIP file..." -ForegroundColor Cyan
$IncludePaths = @(
"backend",
"public",
+ "vendor",
"plugin.json",
"requirements.txt",
"readme"
diff --git a/build.sh b/build.sh
index 822f011..6a4a578 100644
--- a/build.sh
+++ b/build.sh
@@ -81,6 +81,7 @@ cd "$ROOT_DIR"
# Copy required directories and files
cp -r backend "$TEMP_DIR/"
cp -r public "$TEMP_DIR/"
+cp -r vendor "$TEMP_DIR/"
cp plugin.json "$TEMP_DIR/"
cp requirements.txt "$TEMP_DIR/" 2>/dev/null || true
cp readme "$TEMP_DIR/" 2>/dev/null || true
diff --git a/public/luatools.js b/public/luatools.js
index 6764886..0411e27 100644
--- a/public/luatools.js
+++ b/public/luatools.js
@@ -15,7 +15,15 @@
}
}
- backendLog('LuaTools script loaded');
+ try {
+ backendLog('LuaTools script loaded');
+ } catch(e) {
+ // Fallback if backendLog fails during initialization
+ if (typeof console !== 'undefined' && console.log) {
+ console.log('[LuaTools] Script loaded');
+ }
+ }
+
// anti-spam state
const logState = { missingOnce: false, existsOnce: false };
// click/run debounce state
@@ -122,6 +130,1148 @@
} catch(err) { backendLog('LuaTools: Font Awesome injection failed: ' + err); }
}
+ // Function to discover available Steam Client APIs
+ function discoverSteamClientAPIs() {
+ const discovered = [];
+ try {
+ // Check window for Steam-related objects
+ const steamObjects = ['SteamClient', 'g_SteamClient', 'Steam', 'g_Steam', 'SteamUI', 'g_SteamUI'];
+ for (let i = 0; i < steamObjects.length; i++) {
+ const objName = steamObjects[i];
+ if (typeof window[objName] !== 'undefined') {
+ discovered.push(objName + ' exists');
+ const obj = window[objName];
+ if (typeof obj === 'object') {
+ // List all properties
+ try {
+ const keys = Object.keys(obj);
+ for (let j = 0; j < Math.min(keys.length, 20); j++) {
+ const key = keys[j];
+ if (key.toLowerCase().includes('achievement') || key.toLowerCase().includes('stats') || key.toLowerCase().includes('app')) {
+ discovered.push(objName + '.' + key);
+ }
+ }
+ } catch(e) {}
+ }
+ }
+ }
+
+ // Try to find ISteamUserStats interface
+ const statsInterfaces = [
+ 'ISteamUserStats',
+ 'SteamClient.UserStats',
+ 'SteamClient.Stats',
+ 'SteamClient.Apps',
+ 'window.ISteamUserStats'
+ ];
+
+ for (let i = 0; i < statsInterfaces.length; i++) {
+ try {
+ const path = statsInterfaces[i].split('.');
+ let obj = window;
+ for (let j = 0; j < path.length; j++) {
+ obj = obj[path[j]];
+ if (!obj) break;
+ }
+ if (obj && typeof obj === 'object') {
+ discovered.push('Found: ' + statsInterfaces[i]);
+ // List methods
+ const methods = Object.keys(obj);
+ for (let j = 0; j < methods.length; j++) {
+ if (methods[j].toLowerCase().includes('achievement') || methods[j].toLowerCase().includes('get')) {
+ discovered.push(statsInterfaces[i] + '.' + methods[j]);
+ }
+ }
+ }
+ } catch(e) {}
+ }
+ } catch(err) {
+ backendLog('LuaTools: Error discovering APIs: ' + err);
+ }
+
+ if (discovered.length > 0) {
+ backendLog('LuaTools: Discovered Steam APIs: ' + discovered.join(', '));
+ }
+ return discovered;
+ }
+
+ // Function to fetch achievements via SteamClient API or page DOM
+ function fetchAchievementsViaSteamClient(appid, loadingOverlay) {
+ try {
+ backendLog('LuaTools: Attempting to fetch achievements for appid ' + appid);
+
+ // Discover available APIs first
+ discoverSteamClientAPIs();
+
+ // Method 1: Try SteamClient API (if available in Millennium context)
+ try {
+ if (typeof SteamClient !== 'undefined') {
+ backendLog('LuaTools: SteamClient is available');
+
+ // Expanded list of possible methods to try
+ const methods = [
+ 'SteamClient.Apps.GetAchievementProgress',
+ 'SteamClient.Apps.GetAchievements',
+ 'SteamClient.Stats.GetAchievements',
+ 'SteamClient.UserStats.GetAchievements',
+ 'SteamClient.UserStats.GetPlayerAchievements',
+ 'SteamClient.Apps.GetAppStats',
+ 'SteamClient.Apps.GetGameAchievements',
+ 'SteamClient.LocalAchievements.Get',
+ 'SteamClient.LocalStats.GetAchievements',
+ 'ISteamUserStats.GetPlayerAchievements',
+ 'ISteamUserStats.GetAchievementProgress',
+ 'g_SteamClient.UserStats.GetAchievements',
+ 'window.SteamClient.UserStats.GetAchievements'
+ ];
+
+ for (let i = 0; i < methods.length; i++) {
+ try {
+ const methodPath = methods[i].split('.');
+ let obj = window;
+ for (let j = 0; j < methodPath.length; j++) {
+ obj = obj[methodPath[j]];
+ if (!obj) break;
+ }
+
+ if (typeof obj === 'function') {
+ backendLog('LuaTools: Found method: ' + methods[i]);
+ const result = obj(appid);
+ if (result && typeof result.then === 'function') {
+ // It's a promise
+ backendLog('LuaTools: Method returns promise, waiting for result...');
+ result.then(function(achievements) {
+ try {
+ if (loadingOverlay && typeof loadingOverlay.remove === 'function') loadingOverlay.remove();
+ backendLog('LuaTools: Got achievements data: ' + JSON.stringify(achievements).substring(0, 200));
+ if (achievements && Array.isArray(achievements)) {
+ const unlocked = achievements.filter(function(ach) {
+ return ach && (ach.bUnlocked === true || ach.unlocked === true || ach.achieved === true || ach.unlocktime > 0);
+ });
+ backendLog('LuaTools: Filtered to ' + unlocked.length + ' unlocked achievements');
+ if (typeof showAchievementsPopup === 'function') {
+ showAchievementsPopup(appid, {
+ success: true,
+ achievements: unlocked.map(function(ach) {
+ return {
+ id: ach.apiname || ach.id || ach.achievement_id || '',
+ name: ach.name || ach.displayName || ach.localized_name || '',
+ unlocktime: ach.unlocktime || ach.rtimeUnlocked || '1'
+ };
+ }),
+ total_count: unlocked.length,
+ source: 'SteamClient API: ' + methods[i]
+ });
+ return; // Success!
+ }
+ } else if (achievements && typeof achievements === 'object') {
+ // Try to extract achievements from object
+ backendLog('LuaTools: Achievements is object, trying to extract...');
+ const achievementsList = achievements.achievements || achievements.playerstats || achievements.stats || [];
+ if (Array.isArray(achievementsList) && achievementsList.length > 0) {
+ const unlocked = achievementsList.filter(function(ach) {
+ return ach && (ach.bUnlocked === true || ach.unlocked === true || ach.achieved === true || ach.rtimeUnlocked > 0);
+ });
+ if (typeof showAchievementsPopup === 'function') {
+ showAchievementsPopup(appid, {
+ success: true,
+ achievements: unlocked.map(function(ach) {
+ return {
+ id: ach.apiname || ach.id || ach.achievement_id || '',
+ name: ach.name || ach.displayName || ach.localized_name || '',
+ unlocktime: ach.unlocktime || ach.rtimeUnlocked || '1'
+ };
+ }),
+ total_count: unlocked.length,
+ source: 'SteamClient API: ' + methods[i]
+ });
+ return; // Success!
+ }
+ }
+ }
+ // If we got here, didn't work with this method
+ if (i === methods.length - 1 && typeof tryAlternativeAchievementMethod === 'function') {
+ tryAlternativeAchievementMethod(appid, loadingOverlay, null);
+ }
+ } catch(e) {
+ backendLog('LuaTools: Error in promise handler: ' + e);
+ if (i === methods.length - 1 && typeof tryAlternativeAchievementMethod === 'function') {
+ tryAlternativeAchievementMethod(appid, loadingOverlay, null);
+ }
+ }
+ }).catch(function(err) {
+ backendLog('LuaTools: Method ' + methods[i] + ' failed: ' + err);
+ if (i === methods.length - 1 && typeof tryAlternativeAchievementMethod === 'function') {
+ tryAlternativeAchievementMethod(appid, loadingOverlay, null);
+ }
+ });
+ return; // Don't try other methods, wait for promise
+ } else if (result && typeof result === 'object') {
+ // Synchronous result
+ backendLog('LuaTools: Method returned synchronous result');
+ // Process synchronously (same logic as promise handler above)
+ }
+ }
+ } catch(e) {
+ // Method not available, continue
+ }
+ }
+ } else {
+ backendLog('LuaTools: SteamClient is not available in window');
+ }
+ } catch(err) {
+ backendLog('LuaTools: Error checking SteamClient: ' + err);
+ }
+
+ // Method 2: Try to read from current page DOM immediately
+ if (typeof tryAlternativeAchievementMethod === 'function') {
+ tryAlternativeAchievementMethod(appid, loadingOverlay, null);
+ } else {
+ backendLog('LuaTools: tryAlternativeAchievementMethod not available');
+ if (loadingOverlay && typeof loadingOverlay.remove === 'function') loadingOverlay.remove();
+ }
+ } catch(err) {
+ backendLog('LuaTools: Error in fetchAchievementsViaSteamClient: ' + err);
+ if (loadingOverlay && typeof loadingOverlay.remove === 'function') loadingOverlay.remove();
+ }
+ }
+
+ // Alternative method: Try to get achievements from page or navigate to achievements page
+ function tryAlternativeAchievementMethod(appid, loadingOverlay, steamid) {
+ // Debug: Log current page info
+ const currentUrl = window.location.href;
+ backendLog('LuaTools: Current page URL: ' + currentUrl);
+ backendLog('LuaTools: Looking for achievements on page for appid: ' + appid);
+
+ // Method 1: Try to get achievements from current page DOM
+ try {
+ // Check multiple possible selectors for achievements on Steam pages
+ // Steam uses different selectors in different contexts:
+ // - Library achievements page: .achieveRow, .achieveRow.unlocked
+ // - Community achievements: .achievement, .achievement.unlocked
+ // - Stats page: .achieveRow, [data-achievement-id]
+ const selectors = [
+ '.achieveRow',
+ '.achievement',
+ '[data-achievement-id]',
+ '.achieveRow.unlocked',
+ '.achievement.unlocked',
+ '.achieveRow.achieved',
+ '.achievement.achieved',
+ 'div[class*="achieve"]',
+ 'div[class*="achievement"]'
+ ];
+
+ let achievementElements = [];
+ let foundSelector = null;
+
+ backendLog('LuaTools: Trying to find achievement elements on page...');
+ for (let i = 0; i < selectors.length; i++) {
+ try {
+ const elements = document.querySelectorAll(selectors[i]);
+ if (elements && elements.length > 0) {
+ achievementElements = Array.from(elements);
+ foundSelector = selectors[i];
+ backendLog('LuaTools: Found ' + elements.length + ' elements using selector: ' + selectors[i]);
+ break;
+ } else {
+ backendLog('LuaTools: Selector "' + selectors[i] + '" found 0 elements');
+ }
+ } catch(e) {
+ backendLog('LuaTools: Error with selector "' + selectors[i] + '": ' + e);
+ }
+ }
+
+ // If no elements found, log page structure for debugging
+ if (achievementElements.length === 0) {
+ backendLog('LuaTools: No achievement elements found. Page structure debug:');
+ backendLog('LuaTools: - Page title: ' + (document.title || 'N/A'));
+ backendLog('LuaTools: - Body classes: ' + (document.body ? document.body.className : 'N/A'));
+
+ // Try to find any achievement-related text or elements
+ const allDivs = document.querySelectorAll('div');
+ let achievementRelatedCount = 0;
+ for (let i = 0; i < Math.min(allDivs.length, 100); i++) {
+ const text = allDivs[i].textContent || '';
+ const className = allDivs[i].className || '';
+ if (text.toLowerCase().includes('achievement') || className.toLowerCase().includes('achieve')) {
+ achievementRelatedCount++;
+ }
+ }
+ backendLog('LuaTools: - Found ' + achievementRelatedCount + ' divs with "achievement" in text/class (checked first 100)');
+ }
+
+ if (achievementElements.length > 0) {
+ const unlockedAchievements = [];
+
+ achievementElements.forEach(function(el) {
+ try {
+ // Check if achievement is unlocked
+ const isUnlocked = el.classList.contains('unlocked') ||
+ el.classList.contains('achieved') ||
+ el.querySelector('.achieveUnlockTime') !== null ||
+ el.querySelector('.achieveUnlockDate') !== null ||
+ (el.getAttribute('data-achievement-unlocked') === 'true');
+
+ if (isUnlocked) {
+ // Try to get achievement name
+ const nameSelectors = [
+ '.achieveTxtHolder h3',
+ '.achievementName',
+ 'h3',
+ '.achieveTxt',
+ '[data-achievement-name]'
+ ];
+
+ let name = 'Unknown Achievement';
+ for (let j = 0; j < nameSelectors.length; j++) {
+ const nameEl = el.querySelector(nameSelectors[j]);
+ if (nameEl && nameEl.textContent) {
+ name = nameEl.textContent.trim();
+ break;
+ }
+ }
+
+ // Try to get achievement ID
+ let id = '';
+ const idEl = el.querySelector('[data-achievement-id]');
+ if (idEl) {
+ id = idEl.getAttribute('data-achievement-id');
+ } else if (el.getAttribute('data-achievement-id')) {
+ id = el.getAttribute('data-achievement-id');
+ } else {
+ id = name; // Use name as ID if no ID found
+ }
+
+ // Try to get unlock time
+ let unlocktime = '1';
+ const timeEl = el.querySelector('.achieveUnlockTime, .achieveUnlockDate');
+ if (timeEl && timeEl.textContent) {
+ // Try to parse date
+ try {
+ const dateText = timeEl.textContent.trim();
+ unlocktime = dateText;
+ } catch(e) {}
+ }
+
+ unlockedAchievements.push({
+ id: id,
+ name: name,
+ unlocktime: unlocktime
+ });
+ }
+ } catch(e) {
+ backendLog('LuaTools: Error parsing achievement element: ' + e);
+ }
+ });
+
+ if (unlockedAchievements.length > 0) {
+ loadingOverlay.remove();
+ backendLog('LuaTools: Found ' + unlockedAchievements.length + ' unlocked achievements from page DOM');
+ showAchievementsPopup(appid, {
+ success: true,
+ achievements: unlockedAchievements,
+ total_count: unlockedAchievements.length
+ });
+ return;
+ } else {
+ backendLog('LuaTools: Found achievement elements but none are marked as unlocked');
+ }
+ }
+ } catch(err) {
+ backendLog('LuaTools: Error reading achievements from page: ' + err);
+ }
+
+ // Method 2: Try to fetch from Steam Web API (public, no key needed for global stats)
+ // But we need SteamID for player-specific achievements
+ // For now, show message with option to navigate to achievements page
+ loadingOverlay.remove();
+
+ // Try to get SteamID from page (if not already provided as parameter)
+ if (!steamid) {
+ try {
+ if (window.g_steamID) {
+ steamid = window.g_steamID;
+ } else if (window.g_CurrentUser && window.g_CurrentUser.steamid) {
+ steamid = window.g_CurrentUser.steamid;
+ } else if (typeof SteamClient !== 'undefined' && SteamClient.User && SteamClient.User.GetSteamId) {
+ steamid = SteamClient.User.GetSteamId();
+ }
+ } catch(e) {
+ backendLog('LuaTools: Could not get SteamID: ' + e);
+ }
+ }
+
+ // Show popup with option to navigate to achievements page
+ const popup = document.createElement('div');
+ popup.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:99999;display:flex;align-items:center;justify-content:center;';
+
+ const content = document.createElement('div');
+ content.style.cssText = 'background:#1b2838;border-radius:8px;padding:24px;max-width:500px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 4px 20px rgba(0,0,0,0.5);';
+
+ const title = document.createElement('div');
+ title.style.cssText = 'font-size:20px;font-weight:600;color:#fff;margin-bottom:16px;';
+ title.textContent = 'Achievements';
+ content.appendChild(title);
+
+ const msg = document.createElement('div');
+ msg.style.cssText = 'font-size:14px;color:#8f98a0;margin-bottom:20px;line-height:1.5;';
+
+ // Create detailed explanation
+ const explanation = document.createElement('div');
+ explanation.style.cssText = 'margin-bottom:16px;';
+
+ const reasonTitle = document.createElement('div');
+ reasonTitle.style.cssText = 'font-weight:600;color:#66c0f4;margin-bottom:8px;';
+ reasonTitle.textContent = 'Por que não conseguiu encontrar?';
+ explanation.appendChild(reasonTitle);
+
+ const reasonList = document.createElement('ul');
+ reasonList.style.cssText = 'margin:0;padding-left:20px;color:#8f98a0;';
+
+ const reasons = [
+ 'Você precisa estar na página de achievements do jogo na biblioteca do Steam',
+ 'O código procura por elementos HTML específicos (.achieveRow, .achievement) que só existem na página de achievements',
+ 'Se você está na página da loja ou na biblioteca geral, esses elementos não existem',
+ 'Steam não armazena achievements em arquivos locais - eles são carregados dinamicamente quando você abre a página de achievements'
+ ];
+
+ reasons.forEach(function(reason) {
+ const li = document.createElement('li');
+ li.style.cssText = 'margin-bottom:4px;';
+ li.textContent = reason;
+ reasonList.appendChild(li);
+ });
+
+ explanation.appendChild(reasonList);
+ msg.appendChild(explanation);
+
+ const solution = document.createElement('div');
+ solution.style.cssText = 'margin-top:12px;padding:12px;background:rgba(102,192,244,0.1);border-radius:4px;border:1px solid rgba(102,192,244,0.2);';
+ solution.textContent = 'Solução: Clique no botão abaixo para abrir a página de achievements do jogo, depois use o botão "List Achievements" novamente.';
+ msg.appendChild(solution);
+
+ content.appendChild(msg);
+
+ const buttonContainer = document.createElement('div');
+ buttonContainer.style.cssText = 'display:flex;gap:12px;justify-content:flex-end;';
+
+ const openBtn = document.createElement('button');
+ openBtn.style.cssText = 'background:#66c0f4;color:#1b2838;border:none;padding:10px 20px;border-radius:4px;font-size:14px;font-weight:600;cursor:pointer;';
+ openBtn.textContent = 'Open Achievements Page';
+ openBtn.onclick = function() {
+ // Try to open achievements page
+ const achievementsUrl = 'steam://url/GameAchievementsPage/' + appid;
+ try {
+ window.location.href = achievementsUrl;
+ } catch(e) {
+ // Fallback: try to navigate to web page
+ window.open('https://steamcommunity.com/profiles/' + (steamid || '') + '/stats/' + appid + '/achievements', '_blank');
+ }
+ popup.remove();
+ };
+ buttonContainer.appendChild(openBtn);
+
+ const closeBtn = document.createElement('button');
+ closeBtn.style.cssText = 'background:rgba(255,255,255,0.1);color:#fff;border:none;padding:10px 20px;border-radius:4px;font-size:14px;font-weight:600;cursor:pointer;';
+ closeBtn.textContent = 'Close';
+ closeBtn.onclick = function() {
+ popup.remove();
+ };
+ buttonContainer.appendChild(closeBtn);
+
+ content.appendChild(buttonContainer);
+ popup.appendChild(content);
+
+ popup.onclick = function(e) {
+ if (e.target === popup) {
+ popup.remove();
+ }
+ };
+
+ document.body.appendChild(popup);
+ }
+
+ // Function to show achievements popup
+ function showAchievementsPopup(appid, achievementsData) {
+ // Avoid duplicates
+ if (document.querySelector('.luatools-achievements-overlay')) return;
+
+ ensureLuaToolsAnimations();
+ const overlay = document.createElement('div');
+ overlay.className = 'luatools-achievements-overlay';
+ overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);backdrop-filter:blur(8px);z-index:99999;display:flex;align-items:center;justify-content:center;animation:fadeIn 0.2s ease-out;';
+
+ const modal = document.createElement('div');
+ modal.style.cssText = 'background:linear-gradient(135deg, #1b2838 0%, #2a475e 100%);color:#fff;border:2px solid #66c0f4;border-radius:8px;min-width:500px;max-width:700px;max-height:80vh;padding:28px 32px;box-shadow:0 20px 60px rgba(0,0,0,.8), 0 0 0 1px rgba(102,192,244,0.3);animation:slideUp 0.1s ease-out;overflow-y:auto;';
+
+ const title = document.createElement('div');
+ title.style.cssText = 'font-size:22px;color:#fff;margin-bottom:16px;font-weight:700;text-shadow:0 2px 8px rgba(102,192,244,0.4);background:linear-gradient(135deg, #66c0f4 0%, #a4d7f5 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;';
+ title.textContent = 'Achievements';
+
+ const closeBtn = document.createElement('button');
+ closeBtn.style.cssText = 'position:absolute;top:16px;right:16px;background:none;border:none;color:#66c0f4;font-size:24px;cursor:pointer;padding:4px 8px;';
+ closeBtn.innerHTML = '×';
+ closeBtn.onclick = function() { overlay.remove(); };
+
+ const content = document.createElement('div');
+ content.style.cssText = 'max-height:60vh;overflow-y:auto;';
+
+ if (achievementsData && achievementsData.success && achievementsData.achievements && achievementsData.achievements.length > 0) {
+ // Filter only unlocked achievements
+ const unlockedAchievements = achievementsData.achievements.filter(ach => ach.unlocked === true);
+
+ if (unlockedAchievements.length > 0) {
+ const count = document.createElement('div');
+ count.style.cssText = 'font-size:14px;color:#8f98a0;margin-bottom:16px;';
+ count.textContent = `You have unlocked ${unlockedAchievements.length} achievement(s)`;
+ content.appendChild(count);
+
+ const list = document.createElement('div');
+ list.style.cssText = 'display:flex;flex-direction:column;gap:8px;';
+
+ unlockedAchievements.forEach(function(ach) {
+ const item = document.createElement('div');
+ item.style.cssText = 'background:rgba(42,71,94,0.5);padding:12px;border-radius:6px;border:1px solid rgba(102,192,244,0.2);';
+
+ const name = document.createElement('div');
+ name.style.cssText = 'font-size:14px;font-weight:600;color:#fff;margin-bottom:4px;';
+ name.textContent = ach.name || ach.id || 'Unknown Achievement';
+
+ const unlockInfo = document.createElement('div');
+ unlockInfo.style.cssText = 'font-size:12px;color:#66c0f4;';
+ if (ach.unlocktime && ach.unlocktime !== '0' && ach.unlocktime !== 0) {
+ const unlockDate = new Date(parseInt(ach.unlocktime) * 1000);
+ unlockInfo.textContent = `Unlocked: ${unlockDate.toLocaleDateString()} ${unlockDate.toLocaleTimeString()}`;
+ } else {
+ unlockInfo.textContent = 'Unlocked';
+ }
+
+ item.appendChild(name);
+ item.appendChild(unlockInfo);
+ list.appendChild(item);
+ });
+
+ content.appendChild(list);
+ } else {
+ const noAchievementsMsg = document.createElement('div');
+ noAchievementsMsg.style.cssText = 'padding:20px;text-align:center;color:#8f98a0;';
+ noAchievementsMsg.textContent = 'No achievements unlocked yet for this game.';
+ content.appendChild(noAchievementsMsg);
+ }
+ } else {
+ const errorMsg = document.createElement('div');
+ errorMsg.style.cssText = 'padding:20px;text-align:center;color:#8f98a0;';
+ if (achievementsData && achievementsData.error) {
+ errorMsg.textContent = `Error: ${achievementsData.error}`;
+ } else if (achievementsData && achievementsData.achievements && achievementsData.achievements.length === 0) {
+ errorMsg.textContent = 'No achievements unlocked yet for this game.';
+ } else {
+ errorMsg.textContent = 'Failed to load achievements. Make sure you have played this game.';
+ }
+ content.appendChild(errorMsg);
+ }
+
+ modal.appendChild(title);
+ modal.appendChild(closeBtn);
+ modal.appendChild(content);
+ overlay.appendChild(modal);
+ document.body.appendChild(overlay);
+
+ // Close on overlay click (outside modal)
+ overlay.addEventListener('click', function(e) {
+ if (e.target === overlay) {
+ overlay.remove();
+ }
+ });
+ }
+
+ // Dev Console - estilo Chrome DevTools
+ function showDevConsole() {
+ if (document.querySelector('.luatools-dev-console-overlay')) return;
+
+ ensureLuaToolsAnimations();
+ ensureFontAwesome();
+
+ const overlay = document.createElement('div');
+ overlay.className = 'luatools-dev-console-overlay';
+ overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:99999;display:flex;flex-direction:column;font-family:"Consolas","Monaco","Courier New",monospace;';
+
+ const header = document.createElement('div');
+ header.style.cssText = 'background:#1b2838;padding:12px 16px;border-bottom:2px solid #66c0f4;display:flex;justify-content:space-between;align-items:center;';
+
+ const title = document.createElement('div');
+ title.style.cssText = 'color:#66c0f4;font-size:16px;font-weight:600;display:flex;align-items:center;gap:8px;';
+ title.innerHTML = ' LuaTools Dev Console';
+ header.appendChild(title);
+
+ const closeBtn = document.createElement('button');
+ closeBtn.style.cssText = 'background:none;border:none;color:#66c0f4;font-size:20px;cursor:pointer;padding:4px 8px;';
+ closeBtn.innerHTML = '×';
+ closeBtn.onclick = function() { overlay.remove(); };
+ header.appendChild(closeBtn);
+
+ const output = document.createElement('div');
+ output.className = 'luatools-console-output';
+ output.style.cssText = 'flex:1;overflow-y:auto;background:#0d1117;color:#c9d1d9;padding:16px;font-size:13px;line-height:1.5;';
+
+ const inputContainer = document.createElement('div');
+ inputContainer.style.cssText = 'background:#161b22;border-top:2px solid #66c0f4;padding:12px;display:flex;gap:8px;align-items:center;position:relative;';
+
+ const prompt = document.createElement('span');
+ prompt.style.cssText = 'color:#66c0f4;font-weight:600;user-select:none;';
+ prompt.textContent = '> ';
+
+ const inputWrapper = document.createElement('div');
+ inputWrapper.style.cssText = 'flex:1;position:relative;';
+
+ const input = document.createElement('input');
+ input.type = 'text';
+ input.className = 'luatools-console-input';
+ input.style.cssText = 'width:100%;background:#0d1117;border:1px solid #30363d;color:#c9d1d9;padding:8px 12px;border-radius:4px;font-family:inherit;font-size:13px;outline:none;box-sizing:border-box;';
+ input.placeholder = 'Digite comandos JavaScript aqui... (ex: window.SteamClient)';
+
+ // Autocomplete dropdown
+ const autocompleteDropdown = document.createElement('div');
+ autocompleteDropdown.className = 'luatools-autocomplete-dropdown';
+ autocompleteDropdown.style.cssText = 'position:absolute;bottom:100%;left:0;right:0;max-height:200px;overflow-y:auto;background:#161b22;border:1px solid #66c0f4;border-radius:4px;margin-bottom:4px;display:none;z-index:100000;box-shadow:0 4px 12px rgba(0,0,0,0.5);';
+ inputWrapper.appendChild(input);
+ inputWrapper.appendChild(autocompleteDropdown);
+
+ const history = [];
+ let historyIndex = -1;
+ let autocompleteSuggestions = [];
+ let autocompleteIndex = -1;
+ let currentText = '';
+
+ // Lista de objetos e propriedades conhecidos para autocomplete
+ const knownGlobals = [
+ 'window', 'document', 'console', 'SteamClient', 'Millennium',
+ 'g_steamID', 'g_CurrentUser', 'location', 'history'
+ ];
+
+ const steamClientMethods = [
+ 'Apps', 'User', 'Stats', 'UserStats', 'LocalStorage', 'Settings'
+ ];
+
+ // Função para obter propriedades de um objeto
+ function getObjectProperties(obj, maxDepth, currentDepth) {
+ if (currentDepth >= maxDepth || !obj || typeof obj !== 'object') return [];
+ const props = [];
+ try {
+ const keys = Object.keys(obj);
+ for (let i = 0; i < Math.min(keys.length, 50); i++) {
+ props.push(keys[i]);
+ }
+ } catch(e) {}
+ return props;
+ }
+
+ // Função para buscar sugestões
+ function getSuggestions(text) {
+ if (!text || text.trim() === '') return [];
+
+ const suggestions = [];
+ const parts = text.split('.');
+ const lastPart = parts[parts.length - 1];
+ const prefix = parts.slice(0, -1).join('.');
+
+ // Se está digitando uma propriedade (tem ponto)
+ if (parts.length > 1 && prefix) {
+ try {
+ // Tenta avaliar o prefixo para obter o objeto
+ const obj = eval(prefix);
+ if (obj && typeof obj === 'object') {
+ const props = getObjectProperties(obj, 2, 0);
+ props.forEach(function(prop) {
+ const propLower = prop.toLowerCase();
+ const lastLower = lastPart.toLowerCase();
+ if (propLower.includes(lastLower) || lastPart === '') {
+ const type = typeof obj[prop];
+ let typeLabel = type;
+ if (Array.isArray(obj[prop])) typeLabel = 'array';
+ else if (type === 'function') typeLabel = 'function';
+
+ suggestions.push({
+ text: prefix + '.' + prop,
+ label: prop,
+ type: typeLabel,
+ priority: propLower.startsWith(lastLower) ? 1 : 2
+ });
+ }
+ });
+ }
+ } catch(e) {
+ // Prefixo inválido, ignorar
+ }
+ } else {
+ // Buscar em globais conhecidos
+ knownGlobals.forEach(function(global) {
+ const globalLower = global.toLowerCase();
+ const lastLower = lastPart.toLowerCase();
+ if (globalLower.includes(lastLower) || lastPart === '') {
+ try {
+ if (typeof window[global] !== 'undefined') {
+ suggestions.push({
+ text: global,
+ label: global,
+ type: typeof window[global],
+ priority: globalLower.startsWith(lastLower) ? 1 : 2
+ });
+ }
+ } catch(e) {}
+ }
+ });
+
+ // Adicionar métodos conhecidos do SteamClient
+ if (lastPart.toLowerCase().includes('steam') || lastPart === '') {
+ steamClientMethods.forEach(function(method) {
+ const methodLower = method.toLowerCase();
+ const lastLower = lastPart.toLowerCase();
+ if (methodLower.includes(lastLower) || lastPart === '') {
+ suggestions.push({
+ text: 'SteamClient.' + method,
+ label: 'SteamClient.' + method,
+ type: 'object',
+ priority: methodLower.startsWith(lastLower) ? 1 : 2
+ });
+ }
+ });
+ }
+
+ // Buscar propriedades de SteamClient se existir
+ if (typeof SteamClient !== 'undefined') {
+ try {
+ const props = getObjectProperties(SteamClient, 1, 0);
+ props.forEach(function(prop) {
+ const propLower = prop.toLowerCase();
+ const lastLower = lastPart.toLowerCase();
+ if (propLower.includes(lastLower) || lastPart === '') {
+ const type = typeof SteamClient[prop];
+ suggestions.push({
+ text: 'SteamClient.' + prop,
+ label: 'SteamClient.' + prop,
+ type: type === 'function' ? 'function' : type,
+ priority: propLower.startsWith(lastLower) ? 1 : 2
+ });
+ }
+ });
+ } catch(e) {}
+ }
+
+ // Sugerir Millennium se relevante
+ if (typeof Millennium !== 'undefined' && (lastPart === '' || 'millennium'.includes(lastPart.toLowerCase()))) {
+ try {
+ const props = getObjectProperties(Millennium, 1, 0);
+ props.forEach(function(prop) {
+ const propLower = prop.toLowerCase();
+ const lastLower = lastPart.toLowerCase();
+ if (propLower.includes(lastLower) || lastPart === '') {
+ suggestions.push({
+ text: 'Millennium.' + prop,
+ label: 'Millennium.' + prop,
+ type: typeof Millennium[prop],
+ priority: propLower.startsWith(lastLower) ? 1 : 2
+ });
+ }
+ });
+ } catch(e) {}
+ }
+ }
+
+ // Ordenar por prioridade e remover duplicatas
+ suggestions.sort(function(a, b) {
+ if (a.priority !== b.priority) return a.priority - b.priority;
+ return a.label.localeCompare(b.label);
+ });
+
+ const unique = [];
+ const seen = {};
+ suggestions.forEach(function(sug) {
+ if (!seen[sug.text] && unique.length < 20) {
+ seen[sug.text] = true;
+ unique.push(sug);
+ }
+ });
+
+ return unique;
+ }
+
+ // Função para mostrar autocomplete
+ function showAutocomplete(suggestions) {
+ if (suggestions.length === 0) {
+ autocompleteDropdown.style.display = 'none';
+ return;
+ }
+
+ autocompleteDropdown.innerHTML = '';
+ autocompleteSuggestions = suggestions;
+ autocompleteIndex = -1;
+
+ suggestions.forEach(function(sug, index) {
+ const item = document.createElement('div');
+ item.className = 'autocomplete-item';
+ item.style.cssText = 'padding:8px 12px;cursor:pointer;border-bottom:1px solid #30363d;color:#c9d1d9;display:flex;justify-content:space-between;align-items:center;';
+ item.onmouseover = function() {
+ item.style.background = '#1f6feb';
+ item.style.color = '#fff';
+ };
+ item.onmouseout = function() {
+ item.style.background = 'transparent';
+ item.style.color = '#c9d1d9';
+ };
+ item.onclick = function() {
+ input.value = sug.text;
+ hideAutocomplete();
+ input.focus();
+ };
+
+ const label = document.createElement('span');
+ label.textContent = sug.label;
+ label.style.cssText = 'font-weight:500;';
+
+ const type = document.createElement('span');
+ type.textContent = sug.type;
+ type.style.cssText = 'font-size:11px;color:#8b949e;margin-left:8px;';
+
+ item.appendChild(label);
+ item.appendChild(type);
+ autocompleteDropdown.appendChild(item);
+ });
+
+ autocompleteDropdown.style.display = 'block';
+ }
+
+ // Função para esconder autocomplete
+ function hideAutocomplete() {
+ autocompleteDropdown.style.display = 'none';
+ autocompleteIndex = -1;
+ }
+
+ // Atualizar autocomplete enquanto digita
+ input.oninput = function(e) {
+ const text = input.value;
+ currentText = text;
+
+ // Não mostrar autocomplete se estiver vazio ou se acabou de executar comando
+ if (!text || text.trim() === '') {
+ hideAutocomplete();
+ return;
+ }
+
+ // Obter sugestões
+ const suggestions = getSuggestions(text);
+ showAutocomplete(suggestions);
+ };
+
+ function addOutput(text, type) {
+ const line = document.createElement('div');
+ line.style.cssText = 'margin-bottom:4px;word-wrap:break-word;';
+
+ if (type === 'error') {
+ line.style.color = '#f85149';
+ line.textContent = '✗ ' + text;
+ } else if (type === 'warn') {
+ line.style.color = '#d29922';
+ line.textContent = '⚠ ' + text;
+ } else if (type === 'info') {
+ line.style.color = '#58a6ff';
+ line.textContent = 'ℹ ' + text;
+ } else if (type === 'command') {
+ line.style.color = '#7c3aed';
+ line.textContent = '> ' + text;
+ } else if (type === 'result') {
+ line.style.color = '#c9d1d9';
+ line.textContent = text;
+ } else {
+ line.textContent = text;
+ }
+
+ output.appendChild(line);
+ output.scrollTop = output.scrollHeight;
+ }
+
+ function executeCommand(cmd) {
+ if (!cmd || !cmd.trim()) return;
+
+ addOutput(cmd, 'command');
+ history.push(cmd);
+ historyIndex = history.length;
+
+ // Comandos especiais
+ const trimmedCmd = cmd.trim();
+ if (trimmedCmd === 'clear' || trimmedCmd === 'cls') {
+ output.innerHTML = '';
+ addOutput('Console cleared', 'info');
+ return;
+ }
+ if (trimmedCmd === 'help') {
+ addOutput('Comandos disponíveis:', 'info');
+ addOutput(' clear / cls - Limpa o console', 'info');
+ addOutput(' help - Mostra esta ajuda', 'info');
+ addOutput(' inspect(obj) - Inspeciona um objeto detalhadamente', 'info');
+ addOutput(' steam() - Mostra informações do Steam Client', 'info');
+ addOutput(' vars() - Lista variáveis globais úteis', 'info');
+ addOutput('', 'result');
+ addOutput('Exemplos:', 'info');
+ addOutput(' SteamClient - Ver o objeto SteamClient', 'info');
+ addOutput(' inspect(SteamClient) - Inspecionar SteamClient detalhadamente', 'info');
+ addOutput(' window.g_steamID - Ver SteamID', 'info');
+ addOutput(' typeof SteamClient.Apps - Verificar tipo', 'info');
+ return;
+ }
+ if (trimmedCmd === 'steam()' || trimmedCmd === 'steam') {
+ addOutput('Steam Client Info:', 'info');
+ if (typeof SteamClient !== 'undefined') {
+ try {
+ const keys = Object.keys(SteamClient).slice(0, 20);
+ addOutput('SteamClient properties: ' + keys.join(', '), 'result');
+ } catch(e) {
+ addOutput('SteamClient exists but error accessing: ' + e, 'warn');
+ }
+ } else {
+ addOutput('SteamClient not found', 'warn');
+ }
+ return;
+ }
+ if (trimmedCmd === 'vars()' || trimmedCmd === 'vars') {
+ addOutput('Variáveis globais úteis:', 'info');
+ const usefulVars = ['window', 'document', 'SteamClient', 'g_steamID', 'g_CurrentUser', 'Millennium'];
+ usefulVars.forEach(function(v) {
+ if (typeof window[v] !== 'undefined') {
+ addOutput(' ' + v + ': exists', 'result');
+ } else {
+ addOutput(' ' + v + ': not found', 'warn');
+ }
+ });
+ return;
+ }
+
+ // Executar JavaScript
+ try {
+ // Check if it's an inspect command
+ if (trimmedCmd.startsWith('inspect(') && trimmedCmd.endsWith(')')) {
+ const objToInspect = trimmedCmd.substring(8, trimmedCmd.length - 1);
+ try {
+ const obj = eval(objToInspect);
+ const inspected = inspect(obj, 0, objToInspect);
+ addOutput(inspected, 'result');
+ } catch(inspectErr) {
+ addOutput('Error inspecting: ' + inspectErr.message, 'error');
+ }
+ return;
+ }
+
+ // Tenta eval primeiro (para expressões)
+ let result;
+ try {
+ // Make inspect available in eval context
+ result = eval('(function(inspect) { return ' + cmd + ' })(' + inspect.toString() + ')');
+ } catch(evalErr) {
+ // Se eval falhar, tenta Function constructor
+ try {
+ result = (new Function('inspect', 'return (' + cmd + ')')(inspect));
+ } catch(funcErr) {
+ throw evalErr; // Use original error
+ }
+ }
+
+ // Formatar resultado
+ if (result === undefined) {
+ addOutput('undefined', 'result');
+ } else if (result === null) {
+ addOutput('null', 'result');
+ } else if (typeof result === 'object') {
+ try {
+ const json = JSON.stringify(result, null, 2);
+ if (json.length > 1000) {
+ addOutput(json.substring(0, 1000) + '... (truncated, use inspect() for details)', 'result');
+ } else {
+ addOutput(json, 'result');
+ }
+ } catch(jsonErr) {
+ addOutput(result.toString() + ' [Object]', 'result');
+ addOutput('Use inspect(' + cmd + ') para ver detalhes', 'info');
+ }
+ } else {
+ addOutput(String(result), 'result');
+ }
+ } catch(err) {
+ addOutput('Error: ' + err.message, 'error');
+ if (err.stack) {
+ addOutput(err.stack, 'error');
+ }
+ }
+ }
+
+ // Função helper para inspecionar objetos
+ function inspect(obj, depth, path) {
+ depth = depth || 0;
+ path = path || '';
+ if (depth > 3) return '... (max depth)';
+
+ let output = '';
+ if (obj === null) return 'null';
+ if (obj === undefined) return 'undefined';
+ if (typeof obj === 'function') return '[Function]';
+
+ if (typeof obj === 'object') {
+ if (Array.isArray(obj)) {
+ output = 'Array[' + obj.length + '] {\n';
+ for (let i = 0; i < Math.min(obj.length, 10); i++) {
+ output += ' '.repeat(depth + 1) + i + ': ' + inspect(obj[i], depth + 1, path + '[' + i + ']') + ',\n';
+ }
+ if (obj.length > 10) {
+ output += ' '.repeat(depth + 1) + '... (' + (obj.length - 10) + ' more)\n';
+ }
+ output += ' '.repeat(depth) + '}';
+ } else {
+ output = 'Object {\n';
+ const keys = Object.keys(obj).slice(0, 20);
+ for (let i = 0; i < keys.length; i++) {
+ const key = keys[i];
+ try {
+ const value = obj[key];
+ if (typeof value === 'function') {
+ output += ' '.repeat(depth + 1) + key + ': [Function],\n';
+ } else {
+ output += ' '.repeat(depth + 1) + key + ': ' + inspect(value, depth + 1, path + '.' + key) + ',\n';
+ }
+ } catch(e) {
+ output += ' '.repeat(depth + 1) + key + ': [Error accessing],\n';
+ }
+ }
+ if (Object.keys(obj).length > 20) {
+ output += ' '.repeat(depth + 1) + '... (' + (Object.keys(obj).length - 20) + ' more properties)\n';
+ }
+ output += ' '.repeat(depth) + '}';
+ }
+ } else {
+ output = String(obj);
+ }
+
+ return output;
+ }
+
+ // Tornar inspect() disponível globalmente no console
+ window.luatools_inspect = inspect;
+
+ // Mensagem de boas-vindas
+ addOutput('LuaTools Dev Console v1.0', 'info');
+ addOutput('Digite comandos JavaScript ou "help" para ajuda', 'info');
+ addOutput('', 'result');
+
+ input.onkeydown = function(e) {
+ // Se autocomplete está visível, tratar navegação no autocomplete
+ if (autocompleteDropdown.style.display === 'block' && autocompleteSuggestions.length > 0) {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ autocompleteIndex = (autocompleteIndex + 1) % autocompleteSuggestions.length;
+ const items = autocompleteDropdown.querySelectorAll('.autocomplete-item');
+ items.forEach(function(item, idx) {
+ if (idx === autocompleteIndex) {
+ item.style.background = '#1f6feb';
+ item.style.color = '#fff';
+ item.scrollIntoView({ block: 'nearest' });
+ } else {
+ item.style.background = 'transparent';
+ item.style.color = '#c9d1d9';
+ }
+ });
+ return;
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ if (autocompleteIndex <= 0) {
+ autocompleteIndex = autocompleteSuggestions.length - 1;
+ } else {
+ autocompleteIndex--;
+ }
+ const items = autocompleteDropdown.querySelectorAll('.autocomplete-item');
+ items.forEach(function(item, idx) {
+ if (idx === autocompleteIndex) {
+ item.style.background = '#1f6feb';
+ item.style.color = '#fff';
+ item.scrollIntoView({ block: 'nearest' });
+ } else {
+ item.style.background = 'transparent';
+ item.style.color = '#c9d1d9';
+ }
+ });
+ return;
+ } else if (e.key === 'Enter' && autocompleteIndex >= 0) {
+ e.preventDefault();
+ const selected = autocompleteSuggestions[autocompleteIndex];
+ input.value = selected.text;
+ hideAutocomplete();
+ input.focus();
+ return;
+ } else if (e.key === 'Tab' && autocompleteIndex >= 0) {
+ e.preventDefault();
+ const selected = autocompleteSuggestions[autocompleteIndex];
+ input.value = selected.text;
+ hideAutocomplete();
+ input.focus();
+ return;
+ } else if (e.key === 'Escape') {
+ hideAutocomplete();
+ return;
+ }
+ }
+
+ if (e.key === 'Enter') {
+ hideAutocomplete();
+ executeCommand(input.value);
+ input.value = '';
+ currentText = '';
+ } else if (e.key === 'ArrowUp' && autocompleteDropdown.style.display !== 'block') {
+ e.preventDefault();
+ if (historyIndex > 0) {
+ historyIndex--;
+ input.value = history[historyIndex];
+ }
+ } else if (e.key === 'ArrowDown' && autocompleteDropdown.style.display !== 'block') {
+ e.preventDefault();
+ if (historyIndex < history.length - 1) {
+ historyIndex++;
+ input.value = history[historyIndex] || '';
+ } else {
+ historyIndex = history.length;
+ input.value = '';
+ }
+ } else if (e.key === 'Escape') {
+ if (autocompleteDropdown.style.display === 'block') {
+ hideAutocomplete();
+ } else {
+ overlay.remove();
+ }
+ }
+ };
+
+ // Esconder autocomplete ao clicar fora
+ overlay.addEventListener('click', function(e) {
+ if (!inputWrapper.contains(e.target)) {
+ hideAutocomplete();
+ }
+ });
+
+ inputContainer.appendChild(prompt);
+ inputContainer.appendChild(inputWrapper);
+
+ overlay.appendChild(header);
+ overlay.appendChild(output);
+ overlay.appendChild(inputContainer);
+
+ document.body.appendChild(overlay);
+ input.focus();
+
+ // Adicionar comandos especiais ao window para facilitar
+ window.luatools_exec = executeCommand;
+ window.luatools_inspect = inspect;
+
+ addOutput('Dica: Use inspect(obj) para inspecionar objetos detalhadamente', 'info');
+ addOutput('Exemplo: inspect(SteamClient)', 'info');
+ }
+
function showSettingsPopup() {
if (document.querySelector('.luatools-settings-overlay') || settingsMenuPending) return;
settingsMenuPending = true;
@@ -208,6 +1358,12 @@
const checkBtn = createMenuButton('lt-settings-check', 'menu.checkForUpdates', 'Check For Updates', 'fa-cloud-arrow-down');
const fetchApisBtn = createMenuButton('lt-settings-fetch-apis', 'menu.fetchFreeApis', 'Fetch Free APIs', 'fa-server');
+ createSectionLabel('menu.debugLabel', 'MISC');
+ const exportAchievementsBtn = createMenuButton('lt-settings-export-achievements', 'menu.exportAchievements', 'Export Achievements', 'fa-download');
+ const importAchievementsBtn = createMenuButton('lt-settings-import-achievements', 'menu.importAchievements', 'Import Achievements', 'fa-upload');
+ const devConsoleBtn = createMenuButton('lt-settings-dev-console', 'menu.devConsole', 'Dev Console', 'fa-terminal');
+ backendLog('LuaTools: Dev Console button created: ' + (devConsoleBtn ? 'success' : 'failed'));
+
body.appendChild(container);
header.appendChild(title);
@@ -279,6 +1435,225 @@
});
}
+ if (exportAchievementsBtn) {
+ exportAchievementsBtn.addEventListener('click', function(e){
+ e.preventDefault();
+ try { overlay.remove(); } catch(_) {}
+ // Get appid from current page
+ const match = window.location.href.match(/https:\/\/store\.steampowered\.com\/app\/(\d+)/) || window.location.href.match(/https:\/\/steamcommunity\.com\/app\/(\d+)/);
+ const appid = match ? parseInt(match[1], 10) : NaN;
+ if (!isNaN(appid) && typeof Millennium !== 'undefined' && typeof Millennium.callServerMethod === 'function') {
+ // Show loading
+ const loadingOverlay = document.createElement('div');
+ loadingOverlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:99998;display:flex;align-items:center;justify-content:center;';
+ const loadingText = document.createElement('div');
+ loadingText.style.cssText = 'color:#fff;font-size:18px;';
+ loadingText.textContent = 'Exporting achievements...';
+ loadingOverlay.appendChild(loadingText);
+ document.body.appendChild(loadingOverlay);
+
+ // Get all achievements (including locked ones for backup)
+ Millennium.callServerMethod('luatools', 'GetAllAchievementsForApp', { appid, contentScriptQuery: '' }).then(function(res){
+ loadingOverlay.remove();
+ try {
+ const payload = typeof res === 'string' ? JSON.parse(res) : res;
+ if (payload && payload.success && payload.achievements && payload.achievements.length > 0) {
+ // Create backup JSON
+ const backupData = {
+ appid: appid,
+ export_date: new Date().toISOString(),
+ achievements: payload.achievements
+ };
+
+ // Download as JSON file
+ const dataStr = JSON.stringify(backupData, null, 2);
+ const dataBlob = new Blob([dataStr], {type: 'application/json'});
+ const url = URL.createObjectURL(dataBlob);
+
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `achievements_${appid}.json`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+
+ ShowLuaToolsAlert('LuaTools', `Exported ${payload.achievements.length} achievements to achievements_${appid}.json`);
+ } else {
+ ShowLuaToolsAlert('LuaTools', 'No achievements found to export.');
+ }
+ } catch (parseErr) {
+ backendLog('LuaTools: Failed to parse export response: ' + parseErr);
+ ShowLuaToolsAlert('LuaTools', 'Failed to export achievements.');
+ }
+ }).catch(function(err){
+ loadingOverlay.remove();
+ backendLog('LuaTools: Failed to export achievements: ' + err);
+ ShowLuaToolsAlert('LuaTools', 'Failed to export achievements.');
+ });
+ } else {
+ backendLog('LuaTools: Could not determine appid from URL');
+ ShowLuaToolsAlert('LuaTools', 'Could not determine game ID from URL. Please navigate to the game\'s store page or community page.');
+ }
+ });
+ }
+
+ if (importAchievementsBtn) {
+ importAchievementsBtn.addEventListener('click', function(e){
+ e.preventDefault();
+ try { overlay.remove(); } catch(_) {}
+
+ // Create file input for JSON selection
+ const fileInput = document.createElement('input');
+ fileInput.type = 'file';
+ fileInput.accept = '.json';
+ fileInput.style.display = 'none';
+
+ fileInput.addEventListener('change', function(){
+ const file = fileInput.files[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = function(e) {
+ try {
+ const backupData = JSON.parse(e.target.result);
+ backendLog('LuaTools: Loaded backup data: ' + JSON.stringify(backupData).substring(0, 200) + '...');
+
+ if (!backupData.achievements || !Array.isArray(backupData.achievements)) {
+ backendLog('LuaTools: Invalid backup format - no achievements array');
+ ShowLuaToolsAlert('LuaTools', 'Invalid backup file format.');
+ return;
+ }
+
+ backendLog('LuaTools: Found ' + backupData.achievements.length + ' achievements in backup');
+
+ const unlockedAchievements = backupData.achievements.filter(a => a.unlocked === true);
+ const unlockedIds = unlockedAchievements.map(a => a.id);
+
+ backendLog('LuaTools: Found ' + unlockedIds.length + ' unlocked achievements in backup');
+
+ if (unlockedIds.length === 0) {
+ ShowLuaToolsAlert('LuaTools', 'No unlocked achievements found in backup.');
+ return;
+ }
+
+ // Show confirmation
+ const confirmMsg = `Found ${unlockedIds.length} unlocked achievements in backup. Import them?`;
+ if (!confirm(confirmMsg)) return;
+
+ // Get appid from current page
+ const match = window.location.href.match(/https:\/\/store\.steampowered\.com\/app\/(\d+)/) || window.location.href.match(/https:\/\/steamcommunity\.com\/app\/(\d+)/);
+ const appid = match ? parseInt(match[1], 10) : NaN;
+ backendLog('LuaTools: Determined appid: ' + appid);
+
+ if (isNaN(appid)) {
+ ShowLuaToolsAlert('LuaTools', 'Could not determine game ID from URL.');
+ return;
+ }
+
+ // Show loading with progress
+ const loadingOverlay = document.createElement('div');
+ loadingOverlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:99998;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:20px;';
+ document.body.appendChild(loadingOverlay);
+
+ const loadingText = document.createElement('div');
+ loadingText.style.cssText = 'color:#fff;font-size:18px;text-align:center;';
+ loadingText.textContent = 'Importing achievements...';
+ loadingOverlay.appendChild(loadingText);
+
+ // Progress bar container
+ const progressContainer = document.createElement('div');
+ progressContainer.style.cssText = 'width:300px;height:20px;background:rgba(255,255,255,0.2);border-radius:10px;overflow:hidden;';
+ loadingOverlay.appendChild(progressContainer);
+
+ // Progress bar
+ const progressBar = document.createElement('div');
+ progressBar.style.cssText = 'width:0%;height:100%;background:linear-gradient(90deg, #66c0f4 0%, #4a90e2 100%);border-radius:10px;transition:width 0.3s ease;';
+ progressContainer.appendChild(progressBar);
+
+ // Progress text
+ const progressText = document.createElement('div');
+ progressText.style.cssText = 'color:#fff;font-size:14px;margin-top:10px;text-align:center;';
+ progressText.textContent = '0 / ' + unlockedIds.length;
+ loadingOverlay.appendChild(progressText);
+
+ // Animate progress bar
+ let progressPercent = 0;
+ const progressInterval = setInterval(() => {
+ if (progressPercent < 90) { // Don't go to 100% until actually done
+ progressPercent += Math.random() * 5; // Random increment for visual effect
+ if (progressPercent > 90) progressPercent = 90;
+ progressBar.style.width = progressPercent + '%';
+ progressText.textContent = Math.floor((progressPercent / 100) * unlockedIds.length) + ' / ' + unlockedIds.length;
+ }
+ }, 200);
+
+ const importData = {
+ appid: appid,
+ achievements_list: unlockedIds,
+ contentScriptQuery: ''
+ };
+
+ backendLog('LuaTools: Calling ImportAchievements with ' + unlockedIds.length + ' unlocked achievement IDs');
+
+ // Import achievements
+ Millennium.callServerMethod('luatools', 'ImportAchievements', importData).then(function(res){
+ // Stop progress animation and complete it
+ clearInterval(progressInterval);
+ progressBar.style.width = '100%';
+ progressText.textContent = unlockedIds.length + ' / ' + unlockedIds.length;
+
+ // Wait a moment to show completion, then remove overlay and show result
+ setTimeout(() => {
+ loadingOverlay.remove();
+ backendLog('LuaTools: ImportAchievements response: ' + res);
+ try {
+ const payload = typeof res === 'string' ? JSON.parse(res) : res;
+ if (payload && payload.success) {
+ const msg = `Successfully imported ${payload.imported || 0} achievements`;
+ if (payload.failed > 0) {
+ msg += `, ${payload.failed} failed`;
+ }
+ ShowLuaToolsAlert('LuaTools', msg);
+ } else {
+ ShowLuaToolsAlert('LuaTools', payload.error || 'Import failed');
+ }
+ } catch (parseErr) {
+ backendLog('LuaTools: Failed to parse import response: ' + parseErr);
+ ShowLuaToolsAlert('LuaTools', 'Import completed but response could not be parsed.');
+ }
+ }, 500);
+ }).catch(function(err){
+ clearInterval(progressInterval);
+ loadingOverlay.remove();
+ backendLog('LuaTools: Failed to import achievements: ' + err);
+ ShowLuaToolsAlert('LuaTools', 'Failed to import achievements.');
+ });
+
+ } catch (parseErr) {
+ backendLog('LuaTools: JSON parse error: ' + parseErr.message);
+ ShowLuaToolsAlert('LuaTools', 'Invalid JSON file: ' + parseErr.message);
+ }
+ };
+
+ reader.readAsText(file);
+ });
+
+ // Trigger file selection
+ document.body.appendChild(fileInput);
+ fileInput.click();
+ document.body.removeChild(fileInput);
+ });
+ }
+
+ if (devConsoleBtn) {
+ devConsoleBtn.addEventListener('click', function(e){
+ e.preventDefault();
+ try { overlay.remove(); } catch(_) {}
+ showDevConsole();
+ });
+ }
+
if (fixesMenuBtn) {
fixesMenuBtn.addEventListener('click', function(e){
e.preventDefault();
diff --git a/scripts/build_zip.ps1 b/scripts/build_zip.ps1
new file mode 100644
index 0000000..b542479
--- /dev/null
+++ b/scripts/build_zip.ps1
@@ -0,0 +1,112 @@
+# Script auxiliar para criar arquivo ZIP do build
+# Usa o mesmo método do build.ps1 original para garantir compatibilidade
+
+param(
+ [string]$RootDir,
+ [string]$OutputZip,
+ [string]$Version = "unknown"
+)
+
+$ErrorActionPreference = "Stop"
+
+# Use .NET to create ZIP (more reliable on Windows)
+# Load required assemblies
+try {
+ Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop
+ Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop
+} catch {
+ # Fallback: Load from GAC
+ [System.Reflection.Assembly]::LoadWithPartialName("System.IO.Compression.FileSystem") | Out-Null
+ [System.Reflection.Assembly]::LoadWithPartialName("System.IO.Compression") | Out-Null
+}
+
+$IncludePaths = @(
+ "backend",
+ "public",
+ "plugin.json",
+ "requirements.txt",
+ "readme"
+)
+
+$ExcludePatterns = @(
+ "__pycache__",
+ "*.pyc",
+ "*.pyo",
+ ".git",
+ ".gitignore",
+ "*.zip",
+ "temp_dl",
+ "data",
+ "update_pending.zip",
+ "update_pending.json",
+ "api.json",
+ "loadedappids.txt",
+ "appidlogs.txt"
+)
+
+try {
+ # Create ZIP file - use the same approach as build.ps1
+ # The enum should be available after loading the assembly
+ $zip = [System.IO.Compression.ZipFile]::Open($OutputZip, [System.IO.Compression.ZipArchiveMode]::Create)
+
+ foreach ($includePath in $IncludePaths) {
+ $fullPath = Join-Path $RootDir $includePath
+
+ if (-not (Test-Path $fullPath)) {
+ Write-Host "WARNING: Path not found: $includePath" -ForegroundColor Yellow
+ continue
+ }
+
+ $item = Get-Item $fullPath
+
+ if ($item.PSIsContainer) {
+ # Add directory recursively
+ $files = Get-ChildItem -Path $fullPath -Recurse -File
+ foreach ($file in $files) {
+ $relativePath = $file.FullName.Substring($RootDir.Length + 1)
+
+ # Check if should be excluded
+ $shouldExclude = $false
+ foreach ($pattern in $ExcludePatterns) {
+ if ($relativePath -like "*$pattern*") {
+ $shouldExclude = $true
+ break
+ }
+ }
+
+ if (-not $shouldExclude) {
+ $entry = $zip.CreateEntry($relativePath.Replace('\', '/'))
+ $entryStream = $entry.Open()
+ $fileStream = [System.IO.File]::OpenRead($file.FullName)
+ $fileStream.CopyTo($entryStream)
+ $fileStream.Close()
+ $entryStream.Close()
+ Write-Host " + $relativePath" -ForegroundColor Gray
+ }
+ }
+ } else {
+ # Add file
+ $relativePath = $item.FullName.Substring($RootDir.Length + 1)
+ $entry = $zip.CreateEntry($relativePath.Replace('\', '/'))
+ $entryStream = $entry.Open()
+ $fileStream = [System.IO.File]::OpenRead($item.FullName)
+ $fileStream.CopyTo($entryStream)
+ $fileStream.Close()
+ $entryStream.Close()
+ Write-Host " + $relativePath" -ForegroundColor Gray
+ }
+ }
+
+ $zip.Dispose()
+
+ $zipSize = (Get-Item $OutputZip).Length / 1MB
+ Write-Host ""
+ Write-Host "[Build completed successfully!]" -ForegroundColor Green
+ Write-Host " File: $OutputZip" -ForegroundColor Cyan
+ Write-Host " Size: $([math]::Round($zipSize, 2)) MB" -ForegroundColor Cyan
+ Write-Host " Version: $Version" -ForegroundColor Cyan
+} catch {
+ Write-Host "ERROR creating ZIP: $_" -ForegroundColor Red
+ exit 1
+}
+
diff --git a/scripts/build_zip.py b/scripts/build_zip.py
new file mode 100644
index 0000000..97d18f4
--- /dev/null
+++ b/scripts/build_zip.py
@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+"""
+Script auxiliar para criar arquivo ZIP do build
+"""
+import os
+import sys
+import zipfile
+from pathlib import Path
+
+def should_exclude(path_str, exclude_patterns):
+ """Verifica se um caminho deve ser excluído"""
+ for pattern in exclude_patterns:
+ if pattern in path_str:
+ return True
+ return False
+
+def main():
+ if len(sys.argv) < 3:
+ print("Usage: build_zip.py ")
+ sys.exit(1)
+
+ root_dir = Path(sys.argv[1])
+ output_zip = sys.argv[2]
+
+ include = ['backend', 'public', 'plugin.json', 'requirements.txt', 'readme']
+ exclude_patterns = [
+ '__pycache__', '.pyc', '.pyo', '.git', '.gitignore', '.zip',
+ 'temp_dl', 'data', 'update_pending.zip', 'update_pending.json',
+ 'api.json', 'loadedappids.txt', 'appidlogs.txt'
+ ]
+
+ with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_DEFLATED) as z:
+ for include_item in include:
+ full_path = root_dir / include_item
+ if full_path.exists():
+ if full_path.is_dir():
+ for file_path in full_path.rglob('*'):
+ if file_path.is_file():
+ rel_path = str(file_path.relative_to(root_dir)).replace('\\', '/')
+ if not should_exclude(rel_path, exclude_patterns):
+ z.write(str(file_path), rel_path)
+ print(f' + {rel_path}')
+ else:
+ rel_path = str(full_path.relative_to(root_dir)).replace('\\', '/')
+ z.write(str(full_path), rel_path)
+ print(f' + {rel_path}')
+
+ print('ZIP created successfully')
+
+if __name__ == '__main__':
+ main()
+
diff --git a/scripts/build_zip_simple.ps1 b/scripts/build_zip_simple.ps1
new file mode 100644
index 0000000..bef49f1
--- /dev/null
+++ b/scripts/build_zip_simple.ps1
@@ -0,0 +1,116 @@
+# Script auxiliar simplificado para criar arquivo ZIP do build
+# Usa Compress-Archive do PowerShell 5.0+ para máxima compatibilidade
+
+param(
+ [string]$RootDir,
+ [string]$OutputZip,
+ [string]$Version = "unknown"
+)
+
+$ErrorActionPreference = "Stop"
+
+$IncludePaths = @(
+ "backend",
+ "public",
+ "vendor",
+ "plugin.json",
+ "requirements.txt",
+ "readme"
+)
+
+$ExcludePatterns = @(
+ "__pycache__",
+ "*.pyc",
+ "*.pyo",
+ ".git",
+ ".gitignore",
+ "*.zip",
+ "temp_dl",
+ "data",
+ "update_pending.zip",
+ "update_pending.json",
+ "api.json",
+ "loadedappids.txt",
+ "appidlogs.txt"
+)
+
+try {
+ # Create temporary directory
+ $tempDir = Join-Path $env:TEMP "luatools_build_$(Get-Date -Format 'yyyyMMddHHmmss')"
+ New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
+
+ try {
+ # Copy files to temp directory, excluding unwanted patterns
+ foreach ($includePath in $IncludePaths) {
+ $fullPath = Join-Path $RootDir $includePath
+
+ if (-not (Test-Path $fullPath)) {
+ Write-Host "WARNING: Path not found: $includePath" -ForegroundColor Yellow
+ continue
+ }
+
+ $item = Get-Item $fullPath
+
+ if ($item.PSIsContainer) {
+ # Copy directory recursively, excluding patterns
+ $files = Get-ChildItem -Path $fullPath -Recurse -File
+ foreach ($file in $files) {
+ $relativePath = $file.FullName.Substring($RootDir.Length + 1)
+
+ # Check if should be excluded
+ $shouldExclude = $false
+ foreach ($pattern in $ExcludePatterns) {
+ if ($relativePath -like "*$pattern*") {
+ $shouldExclude = $true
+ break
+ }
+ }
+
+ if (-not $shouldExclude) {
+ $destPath = Join-Path $tempDir $relativePath
+ $destDir = Split-Path $destPath -Parent
+ New-Item -ItemType Directory -Path $destDir -Force | Out-Null
+ Copy-Item $file.FullName -Destination $destPath -Force
+ Write-Host " + $relativePath" -ForegroundColor Gray
+ }
+ }
+ } else {
+ # Copy file
+ $relativePath = $item.FullName.Substring($RootDir.Length + 1)
+ $destPath = Join-Path $tempDir $relativePath
+ $destDir = Split-Path $destPath -Parent
+ if ($destDir) {
+ New-Item -ItemType Directory -Path $destDir -Force | Out-Null
+ }
+ Copy-Item $item.FullName -Destination $destPath -Force
+ Write-Host " + $relativePath" -ForegroundColor Gray
+ }
+ }
+
+ # Remove existing ZIP if it exists
+ if (Test-Path $OutputZip) {
+ Remove-Item $OutputZip -Force
+ }
+
+ # Create ZIP using Compress-Archive
+ Compress-Archive -Path "$tempDir\*" -DestinationPath $OutputZip -Force
+
+ $zipSize = (Get-Item $OutputZip).Length / 1MB
+ Write-Host ""
+ Write-Host "[Build completed successfully!]" -ForegroundColor Green
+ Write-Host " File: $OutputZip" -ForegroundColor Cyan
+ Write-Host " Size: $([math]::Round($zipSize, 2)) MB" -ForegroundColor Cyan
+ Write-Host " Version: $Version" -ForegroundColor Cyan
+
+ } finally {
+ # Cleanup temp directory
+ if (Test-Path $tempDir) {
+ Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue
+ }
+ }
+
+} catch {
+ Write-Host "ERROR creating ZIP: $_" -ForegroundColor Red
+ exit 1
+}
+
diff --git a/scripts/get_version.py b/scripts/get_version.py
new file mode 100644
index 0000000..a29fd07
--- /dev/null
+++ b/scripts/get_version.py
@@ -0,0 +1,27 @@
+#!/usr/bin/env python3
+"""
+Script auxiliar para ler versão do plugin.json
+"""
+import json
+import sys
+import os
+
+def main():
+ if len(sys.argv) < 2:
+ print("unknown")
+ sys.exit(1)
+
+ plugin_path = os.path.join(sys.argv[1], 'plugin.json')
+
+ try:
+ with open(plugin_path, 'r', encoding='utf-8') as f:
+ data = json.load(f)
+ version = data.get('version', 'unknown')
+ print(version)
+ except Exception:
+ print("unknown")
+ sys.exit(1)
+
+if __name__ == '__main__':
+ main()
+
diff --git a/vendor/SAM/Newtonsoft.Json.dll b/vendor/SAM/Newtonsoft.Json.dll
new file mode 100644
index 0000000..3f6541a
Binary files /dev/null and b/vendor/SAM/Newtonsoft.Json.dll differ
diff --git a/vendor/SAM/SAM.API.dll b/vendor/SAM/SAM.API.dll
new file mode 100644
index 0000000..b113c2d
Binary files /dev/null and b/vendor/SAM/SAM.API.dll differ
diff --git a/vendor/SAM/SAM.CLI.exe b/vendor/SAM/SAM.CLI.exe
new file mode 100644
index 0000000..5034d2f
Binary files /dev/null and b/vendor/SAM/SAM.CLI.exe differ